diff --git a/.env.example b/.env.example
index c6a0c91..67599d7 100644
--- a/.env.example
+++ b/.env.example
@@ -12,7 +12,5 @@ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
SUPABASE_SECRET_KEY=
# Optional direct PostgreSQL connection for maintenance. Runtime uses the Supabase API.
DATABASE_URL=
-# Explicit local guest sandbox when no hosted project is configured.
-MINEGEO_LOCAL_MODE=true
MINEGEO_FACE_SIZE=1024
MINEGEO_MAX_UPLOAD_BYTES=268435456
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..01c26e1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,57 @@
+# MineGeo
+
+A Minecraft panorama guessing game: Next.js website, Fabric capture mod, and standalone Bukkit/Paper exporter. The interface is English; the game uses fixed 360° views and Minecraft X/Z coordinates.
+
+## Run the website
+
+Use Node.js 22 or later. From the repository root on Windows, macOS, or Linux:
+
+```sh
+npm install
+npm run setup
+```
+
+Fill `.env.local` with the dedicated Supabase project's URL, publishable key, and server secret. Apply the SQL files in `supabase/migrations` in filename order to a new project. Never put the server secret in a `NEXT_PUBLIC_` variable. Configure the sign-in callback as described in [LAN and domain setup](docs/local-network.md).
+
+```sh
+npm run dev
+# Production:
+npm run build
+npm start
+```
+
+`npm start` and `npm run dev` supervise both the website and processing worker. The worker is required to turn an upload into a previewable draft. `npm run worker` also loads `.env.local` when run separately. Setup downloads the pinned vanilla rendering assets and the Chromium runtime used by the existing terrain-rendering worker. Browser UI checks are separate from this worker.
+
+`GET /api/v1/health` checks database connectivity. An empty catalog means no map has passed moderator review yet. Moderator access is controlled by `profiles.role` in Supabase; the project owner can set their profile's role to `moderator` using the database dashboard, then use `/moderation` on the website.
+
+## Install the capture tools
+
+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).
+- **Player:** Minecraft 1.21.11, Fabric Loader and Fabric API. Put the mod JAR in `mods/`, press G, enter the website address, link your account, capture at least five positions, and upload.
+
+To rebuild both tools with Java 21 installed:
+
+```sh
+npm run java:build
+```
+
+The build copies both JARs to their Git-visible download folders. Include those files in your commit before pulling on another machine.
+
+## Checks
+
+```sh
+npm test
+npm run typecheck -- --incremental false
+npm run build
+node --import tsx scripts/test-plugin.ts
+```
+
+The plugin regression uses the Paper JAR already downloaded into `.cache/paper-proof/paper.jar`, starts a temporary loopback-only Paper server on port 25580, and stops it automatically. It verifies export, manifest validation, missing-export instructions, interrupted TUS upload/resume, idempotent retries, and cancellation. Its upload endpoint is a local test server; it does not publish a real community map or launch a Minecraft client.
+
+## Rendering scope
+
+Plugin exports include generated Overworld surface terrain (up to 24 blocks below each column's top), vanilla block states, and biome identifiers. Buried opaque blocks are omitted. Each export is limited to 5 million retained blocks, 240 MiB of raw terrain JSON, and 50 MiB compressed. Start with a 128 × 128 area, check the preview, then increase coverage. Lighting is simplified; custom packs, entities, and dynamic block-entity visuals are not captured by the server exporter.
+
+The pixel heading font is VT323 by Peter Hull, distributed under the SIL Open Font License. Its license is in `public/fonts/OFL-VT323.txt`.
diff --git a/app/block-theme.css b/app/block-theme.css
new file mode 100644
index 0000000..2984311
--- /dev/null
+++ b/app/block-theme.css
@@ -0,0 +1,918 @@
+@font-face {
+ font-family: MineGeoPixel;
+ src: url("/fonts/VT323-Regular.ttf") format("truetype");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+:root {
+ --bg: #080c12;
+ --panel: #121b26;
+ --panel2: #192430;
+ --line: #2c3743;
+ --green: #86ce56;
+ --muted: #99a5ae;
+ --text: #edf1f4;
+ --sand: #dcc197;
+}
+body {
+ background-color: var(--bg);
+ background-image: radial-gradient(
+ ellipse at 75% 8%,
+ #18202b66,
+ transparent 52%
+ );
+ color: var(--text);
+}
+h1,
+h2,
+h3,
+.brand,
+.button,
+.header-account,
+.site-header nav,
+.world-stats strong {
+ font-family: MineGeoPixel, "Courier New", monospace;
+ font-weight: 400;
+ letter-spacing: 0;
+}
+h1,
+h2,
+h3 {
+ text-shadow: 2px 2px 0 #0008;
+}
+h1 {
+ font-size: 64px;
+}
+h2 {
+ font-size: 40px;
+}
+h3 {
+ font-size: 28px;
+}
+.button {
+ border: 2px solid #4b5965;
+ border-bottom: 4px solid #03070b;
+ box-shadow:
+ inset 2px 2px #ffffff15,
+ 3px 3px 0 #0008;
+ background: #25313c;
+ font-size: 23px;
+ padding: 7px 20px;
+ line-height: 1.2;
+ border-radius: 0;
+}
+.button.primary {
+ color: #10240b;
+ background: linear-gradient(#94df61, #5da732);
+ border: 2px solid #afd98b;
+ border-bottom: 4px solid #294b17;
+ box-shadow:
+ inset 2px 2px #d7ffb066,
+ 0 3px 0 #050904;
+ font-weight: 400;
+ text-shadow: 1px 1px #d7ffa555;
+}
+.button.primary:hover {
+ background: linear-gradient(#acf77a, #76bd4b);
+}
+.button:hover {
+ transform: translateY(-2px);
+ background-color: #334552;
+}
+.button:active {
+ transform: translateY(1px);
+}
+.site-header {
+ height: 68px;
+ background: #090e16ed;
+ border-bottom: 2px solid #222b37;
+ box-shadow: 0 3px #03070b;
+}
+.header-inner,
+.section,
+.site-footer {
+ max-width: 1160px;
+}
+.header-inner {
+ padding: 0 28px;
+ gap: 24px;
+}
+.brand {
+ font-size: 34px;
+ white-space: nowrap;
+}
+.brand-cube {
+ transform: none;
+ box-shadow: 3px 3px #000;
+ border: 1px solid #748952;
+ border-bottom: 11px solid #785033;
+ background: repeating-linear-gradient(90deg, #6faf3f 0 7px, #81c64b 7px 14px);
+ width: 27px;
+ height: 29px;
+}
+.brand sup {
+ font-size: 8px;
+ margin-top: -10px;
+}
+.site-header nav {
+ font-size: 23px;
+ gap: 25px;
+}
+.header-account {
+ font-size: 21px;
+ padding: 5px 13px;
+ border: 2px solid #9f87c3;
+ border-bottom: 4px solid #45325f;
+ background: linear-gradient(#8560b2, #694890);
+ box-shadow: inset 2px 2px #ffffff20;
+ color: #fff;
+ white-space: nowrap;
+}
+.home-page {
+ position: relative;
+ background-image:
+ radial-gradient(#9dabb332 1px, transparent 2px),
+ radial-gradient(#9dabb318 1px, transparent 2px);
+ background-size:
+ 103px 89px,
+ 179px 151px;
+ background-position:
+ 18px 8px,
+ 62px 40px;
+}
+.hero {
+ max-width: 1160px;
+ min-height: 394px;
+ padding: 44px 28px 46px;
+ grid-template-columns: 1.3fr 1fr;
+ gap: 35px;
+ overflow: visible;
+}
+.hero h1 {
+ font-size: clamp(44px, 4.7vw, 66px);
+ line-height: 0.97;
+ font-weight: 400;
+ letter-spacing: 0;
+ margin-bottom: 18px;
+}
+.hero h1 span {
+ color: #87c855;
+ text-shadow: 3px 3px #23421d;
+}
+.hero p {
+ font-size: 14px;
+ line-height: 1.7;
+ color: #a6afb8;
+ margin-bottom: 19px;
+}
+.status-label {
+ font-size: 10px;
+ border: 1px solid #2b3541;
+ background: #111822;
+ display: inline-flex;
+ padding: 3px 8px;
+ margin-bottom: 15px;
+ letter-spacing: 0.5px;
+}
+.hero-actions {
+ margin-top: 19px;
+ gap: 16px;
+}
+.hero-mode-note {
+ font-size: 11px;
+ color: #94a0aa;
+}
+.hero-notes {
+ margin-top: 20px;
+ align-items: center;
+ color: #9ba6af;
+ font-size: 11px;
+}
+.mode-dots {
+ display: flex;
+ gap: 9px !important;
+}
+.mode-dots i {
+ display: block;
+ width: 13px;
+ height: 13px;
+ background: #6cab43;
+ border: 2px solid #a1c875;
+ box-shadow: 2px 2px #000;
+}
+.mode-dots i:nth-child(2) {
+ background: #cca34c;
+ border-color: #e7c47b;
+}
+.mode-dots i:nth-child(3) {
+ background: #4786b8;
+ border-color: #88b1d1;
+}
+.mode-dots i:nth-child(4) {
+ background: #b64d5b;
+ border-color: #df7d89;
+}
+.hero-art {
+ min-height: 300px;
+ margin: 0;
+}
+.map-art-frame {
+ width: 277px;
+ height: 277px;
+ transform: rotate(-5deg);
+ border: 12px solid var(--sand);
+ box-shadow:
+ 0 0 0 4px #81684a,
+ 8px 8px 0 #0009;
+ clip-path: polygon(
+ 0 3%,
+ 5% 3%,
+ 5% 0,
+ 22% 0,
+ 22% 2%,
+ 43% 2%,
+ 43% 0,
+ 65% 0,
+ 65% 2%,
+ 89% 2%,
+ 89% 0,
+ 97% 0,
+ 97% 7%,
+ 100% 7%,
+ 100% 27%,
+ 98% 27%,
+ 98% 71%,
+ 100% 71%,
+ 100% 95%,
+ 95% 95%,
+ 95% 100%,
+ 70% 100%,
+ 70% 98%,
+ 30% 98%,
+ 30% 100%,
+ 3% 100%,
+ 3% 93%,
+ 0 93%
+ );
+}
+.square-pin {
+ position: absolute;
+ width: 16px;
+ height: 16px;
+ border: 3px solid #16231b;
+ box-shadow: 2px 2px #0008;
+}
+.pin-green {
+ top: 65%;
+ left: 30%;
+ background: #60c8a0;
+}
+.pin-red {
+ top: 40%;
+ left: 56%;
+ background: #cd5367;
+}
+.pin-gold {
+ top: 20%;
+ left: 70%;
+ background: #e4b851;
+}
+.compass-art {
+ right: 12%;
+ top: 0;
+ transform: none;
+ border: 2px solid #535e68;
+ background: linear-gradient(135deg, #26313e, #080d16);
+ box-shadow:
+ inset 3px 3px #ffffff0c,
+ 4px 4px #0008;
+ width: 66px;
+ height: 70px;
+ padding: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #d5dade;
+}
+.compass-art small {
+ position: absolute;
+ top: 2px;
+ left: 27px;
+ font: 10px monospace;
+ color: #dd6270;
+}
+.floating-tag {
+ bottom: 0;
+ right: auto;
+ left: 50%;
+ transform: translateX(-50%);
+ white-space: nowrap;
+ padding: 6px 10px;
+ background: #0d151f;
+ border: 2px solid #273240;
+ display: flex;
+ gap: 9px;
+ font: 11px monospace;
+ color: #b8c1c9;
+}
+.floating-tag strong {
+ color: #eff2f5;
+}
+.floating-tag svg {
+ color: #ce5367;
+}
+.grass-divider {
+ height: 23px;
+ background: repeating-linear-gradient(
+ 90deg,
+ #5d422c 0 8px,
+ #513827 8px 16px,
+ #624731 16px 24px
+ );
+ border-top: 5px solid #578b30;
+ border-bottom: 3px solid #332419;
+ box-shadow: 0 -2px #8db85a;
+}
+.grass-divider span {
+ display: block;
+ height: 6px;
+ background: repeating-linear-gradient(
+ 90deg,
+ #427329 0 13px,
+ transparent 13px 19px,
+ #355d21 19px 26px
+ );
+}
+.world-stats {
+ position: relative;
+ max-width: 1104px;
+ margin: -3px auto 0;
+ background: #131d29;
+ border: 2px solid #303b46;
+ border-top: 0;
+ box-shadow: 4px 4px #0008;
+ padding: 19px 28px;
+ gap: 20px;
+}
+.world-stats > div {
+ gap: 13px;
+ border-right: 1px solid #2e3743;
+}
+.world-stats > div:last-child {
+ border: 0;
+}
+.world-stats > div > div {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+.world-stats strong {
+ font-size: 32px;
+ line-height: 0.95;
+ color: #f0f2f5;
+}
+.world-stats span:not(.live-dot) {
+ font-size: 8px;
+ letter-spacing: 0.3px;
+ max-width: none;
+ color: #929eac;
+}
+.stat-cube {
+ display: block;
+ flex-shrink: 0;
+ width: 25px;
+ height: 25px;
+ border: 2px solid #b5d99b;
+ box-shadow:
+ inset 3px 3px #ffffff25,
+ 3px 3px #000a;
+}
+.green-cube {
+ background: linear-gradient(#8ccb55, #4b8f31);
+}
+.gold-cube {
+ background: linear-gradient(#ebcc65, #b78a32);
+ border-color: #f0d797;
+}
+.cyan-cube {
+ background: linear-gradient(#73c6c3, #378f91);
+ border-color: #a5dfd8;
+}
+.red-cube {
+ background: linear-gradient(#d97988, #a73d52);
+ border-color: #e8a5af;
+}
+.section {
+ padding: 48px 28px;
+}
+.section-heading {
+ margin-bottom: 24px;
+}
+.section-heading h2 {
+ font-size: 38px;
+}
+.eyebrow {
+ font:
+ 11px/1.4 "Courier New",
+ monospace;
+ letter-spacing: 0.5px;
+ margin-bottom: 5px;
+ color: #9ec581;
+}
+.text-link {
+ font-size: 12px;
+ color: #b4c0ca;
+}
+.cards-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 16px;
+}
+.map-card {
+ border: 2px solid #303c49;
+ background: #141e2b;
+ box-shadow: 3px 3px #0008;
+}
+.map-card:hover {
+ border-color: #83ab5f;
+ transform: translateY(-3px);
+}
+.card-image {
+ height: auto;
+ aspect-ratio: 1;
+ margin: 24px 11px 12px;
+ border: 9px solid #d2b98d;
+ outline: 2px solid #876e4f;
+ overflow: visible;
+ box-shadow: 3px 3px #0007;
+}
+.card-type {
+ top: -28px;
+ left: -9px;
+ border: 0;
+ background: #080c12;
+ padding: 1px 4px;
+ color: #d1d9de;
+ letter-spacing: 0;
+ font: 10px monospace;
+}
+.card-number {
+ display: none;
+}
+.card-body {
+ padding: 12px;
+ background: #17212f;
+ border-top: 1px solid #303a45;
+}
+.card-body .eyebrow {
+ font-size: 8px;
+ margin-bottom: 5px;
+}
+.card-body h3 {
+ font-size: 26px;
+ line-height: 1;
+ margin-bottom: 7px;
+ gap: 6px;
+}
+.card-body h3 svg {
+ width: 14px;
+ flex-shrink: 0;
+}
+.card-body p {
+ font-size: 11px;
+ line-height: 1.5;
+ color: #a3adb8;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ min-height: 33px;
+}
+.card-meta {
+ font-size: 9px;
+ padding-top: 9px;
+ color: #9ba9b7;
+ border-color: #35414d;
+ gap: 5px;
+}
+.first-map-panel {
+ display: grid;
+ grid-template-columns: 210px 1fr;
+ gap: 36px;
+ align-items: center;
+ border: 2px solid #2d3b47;
+ background: #111a26;
+ padding: 24px;
+ box-shadow: 5px 5px #0007;
+}
+.first-map-panel h3 {
+ font-size: 35px;
+ margin-bottom: 9px;
+}
+.first-map-panel p {
+ color: #a4aeb9;
+ max-width: 620px;
+ font-size: 13px;
+}
+.empty-map-art {
+ position: relative;
+ border: 10px solid #d4bc91;
+ box-shadow: 3px 3px #7b6040;
+ transform: rotate(-3deg);
+}
+.empty-map-art img {
+ display: block;
+ width: 100%;
+ height: auto;
+ image-rendering: pixelated;
+ filter: saturate(0.65);
+}
+.empty-map-art span {
+ position: absolute;
+ bottom: 12px;
+ left: 50%;
+ transform: translateX(-50%);
+ white-space: nowrap;
+ padding: 4px 7px;
+ background: #090d17dd;
+ font: 11px monospace;
+ color: #f5dfb4;
+}
+.home-panels {
+ display: grid;
+ grid-template-columns: 1.2fr 1fr;
+ gap: 20px;
+ padding-top: 0;
+}
+.home-panel {
+ background: linear-gradient(135deg, #17222e, #101923);
+ border: 2px solid #2e3a48;
+ padding: 24px;
+ box-shadow: 4px 4px #0007;
+}
+.home-panel h2 {
+ font-size: 36px;
+ margin-bottom: 22px;
+}
+.play-steps {
+ list-style: none;
+ margin: 0 0 20px;
+ padding: 0;
+ display: grid;
+ gap: 18px;
+}
+.play-steps li {
+ display: flex;
+ gap: 15px;
+ align-items: flex-start;
+}
+.play-steps li > span {
+ font: 24px MineGeoPixel;
+ color: #9bbb7d;
+ background: #202d23;
+ border: 1px solid #43553a;
+ padding: 0 6px;
+}
+.play-steps h3 {
+ font-size: 25px;
+ margin-bottom: 3px;
+}
+.play-steps p,
+.mode-row p {
+ font-size: 12px;
+ color: #a4afb9;
+ margin: 0;
+}
+.mode-list {
+ display: grid;
+ gap: 10px;
+}
+.mode-row {
+ display: flex;
+ gap: 13px;
+ align-items: center;
+ border: 2px solid #384651;
+ background: #192430;
+ padding: 12px;
+}
+.mode-row:hover {
+ border-color: #84b459;
+}
+.mode-row > div {
+ flex: 1;
+}
+.mode-row h3 {
+ font-size: 25px;
+ margin: 0 0 4px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+.mode-row h3 span {
+ font: 10px monospace;
+ color: #acb9c3;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ white-space: nowrap;
+}
+.mode-footnote {
+ font-size: 11px;
+ color: #899ca9;
+ margin: 16px 0 0;
+}
+.contribute-banner {
+ max-width: 1104px;
+ padding: 25px 28px;
+ margin-bottom: 48px;
+ background: #131e24;
+ border: 2px solid #3a5233;
+ gap: 22px;
+}
+.contribute-banner > div {
+ flex: 1;
+}
+.contribute-banner h2 {
+ font-size: 33px;
+ margin-bottom: 6px;
+}
+.contribute-banner > svg {
+ color: var(--green);
+}
+.site-footer {
+ border-color: #263440;
+}
+.site-footer .brand {
+ font-size: 29px;
+}
+.site-footer p,
+.site-footer > div:nth-child(2) {
+ color: #91a0ad;
+}
+.tool-card,
+.start-panel,
+.auth-card,
+.draft-row,
+.token-box,
+.result-summary,
+.round-result {
+ background: var(--panel);
+ border: 2px solid var(--line);
+ box-shadow: 4px 4px #0007;
+}
+.badge {
+ border-color: #485b3f;
+ background: #1d2c22;
+ color: #a9cf8d;
+}
+.empty-state {
+ background: #111b25;
+ border-color: #43525d;
+}
+.field input,
+.field textarea,
+.search-field {
+ background: #0b121b;
+ border-color: #3c4a56;
+}
+.difficulty-options button {
+ background: #17232d;
+}
+.difficulty-options button.selected {
+ background: #223522;
+}
+.draft-row h3 {
+ font-size: 27px;
+}
+.game-info {
+ background: #111b27e8;
+}
+.game-map {
+ background: #111b27;
+ border: 3px solid #576147;
+}
+.round-result {
+ background: #111b27f5;
+}
+.page-content h1 {
+ font-size: 58px;
+}
+.header-inner .mobile-nav {
+ display: none;
+}
+@media (max-width: 1000px) {
+ .hero {
+ grid-template-columns: 1.2fr 1fr;
+ gap: 15px;
+ }
+ .hero h1 {
+ font-size: 55px;
+ }
+ .world-stats {
+ margin-left: 28px;
+ margin-right: 28px;
+ padding: 17px;
+ }
+ .cards-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .compass-art {
+ right: 1%;
+ }
+ .site-header nav {
+ gap: 15px;
+ }
+ .home-panels {
+ grid-template-columns: 1fr 1fr;
+ }
+ .mode-row h3 {
+ flex-wrap: wrap;
+ gap: 3px;
+ }
+ .contribute-banner {
+ margin-left: 28px;
+ margin-right: 28px;
+ }
+ .brand sup {
+ display: none;
+ }
+}
+@media (max-width: 700px) {
+ .site-header {
+ height: auto;
+ min-height: 66px;
+ }
+ .header-inner {
+ padding: 13px 18px;
+ flex-wrap: wrap;
+ gap: 12px;
+ }
+ .header-inner nav {
+ display: flex;
+ order: 3;
+ flex-basis: 100%;
+ justify-content: space-between;
+ border-top: 1px solid #263340;
+ padding-top: 9px;
+ font-size: 21px;
+ gap: 12px;
+ }
+ .brand {
+ font-size: 30px;
+ }
+ .header-account {
+ font-size: 20px;
+ padding: 4px 9px;
+ }
+ .hero {
+ padding: 32px 22px 28px;
+ gap: 20px;
+ }
+ .hero h1 {
+ font-size: 52px;
+ }
+ .hero p {
+ font-size: 13px;
+ }
+ .hero-art {
+ width: 100%;
+ min-height: 294px;
+ margin: 15px 0 0;
+ }
+ .map-art-frame {
+ width: 254px;
+ height: 254px;
+ }
+ .compass-art {
+ right: calc(50% - 144px);
+ top: 0;
+ }
+ .hero-actions {
+ gap: 15px;
+ flex-wrap: wrap;
+ }
+ .hero-notes {
+ gap: 10px;
+ }
+ .hero-mode-note {
+ font-size: 10px;
+ }
+ .world-stats {
+ margin: 0 18px;
+ padding: 18px;
+ gap: 19px;
+ grid-template-columns: 1fr 1fr;
+ }
+ .world-stats > div {
+ gap: 9px;
+ border: 0;
+ }
+ .world-stats strong {
+ font-size: 27px;
+ }
+ .world-stats span:not(.live-dot) {
+ font-size: 7px;
+ }
+ .stat-cube {
+ width: 22px;
+ height: 22px;
+ }
+ .section {
+ padding: 34px 20px;
+ }
+ .section-heading {
+ align-items: flex-end;
+ gap: 10px;
+ }
+ .section-heading h2 {
+ font-size: 33px;
+ }
+ .section-heading .text-link {
+ font-size: 10px;
+ white-space: nowrap;
+ }
+ .first-map-panel {
+ grid-template-columns: 1fr;
+ padding: 21px;
+ gap: 24px;
+ }
+ .empty-map-art {
+ width: 150px;
+ margin: auto;
+ }
+ .first-map-panel h3 {
+ font-size: 31px;
+ }
+ .home-panels {
+ grid-template-columns: 1fr;
+ padding-top: 0;
+ gap: 18px;
+ }
+ .home-panel {
+ padding: 21px;
+ }
+ .home-panel h2 {
+ font-size: 33px;
+ }
+ .contribute-banner {
+ margin: 0 20px 32px;
+ padding: 22px;
+ gap: 15px;
+ align-items: flex-start;
+ }
+ .contribute-banner > svg {
+ display: none;
+ }
+ .cards-grid {
+ gap: 12px;
+ }
+ .card-image {
+ margin: 23px 9px 9px;
+ border-width: 7px;
+ aspect-ratio: 1;
+ height: auto;
+ }
+ .card-body {
+ padding: 10px;
+ }
+ .card-body h3 {
+ font-size: 24px;
+ }
+ .card-body p {
+ display: none;
+ }
+ .card-body .eyebrow {
+ font-size: 7px;
+ }
+ .card-meta {
+ font-size: 8px;
+ }
+ .card-meta span:last-child {
+ display: none;
+ }
+ .page-content h1 {
+ font-size: 44px;
+ }
+ .tool-card {
+ padding: 23px;
+ }
+ .tool-card .button {
+ font-size: 21px;
+ }
+ .mode-row h3 {
+ flex-wrap: nowrap;
+ }
+ .game-header .brand {
+ font-size: 25px;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation: none !important;
+ transition: none !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/app/downloads/page.tsx b/app/downloads/page.tsx
index 343ea73..e5a6413 100644
--- a/app/downloads/page.tsx
+++ b/app/downloads/page.tsx
@@ -77,13 +77,20 @@ export default function Downloads() {
The plugin build is not available yet.
)}
- /minegeo area world -256 -256 256 256{"\n"}/minegeo export My
- Overworld{"\n"}/minegeo link <dashboard token>{"\n"}/minegeo
- upload
+ {
+ "/minegeo website https://your-minegeo-site.example\n/minegeo link \n/minegeo area world -64 -64 64 64\n/minegeo export My Overworld\n/minegeo status\n# Wait for Export complete, then:\n/minegeo upload"
+ }
+
+ Start with a small, explored area around your builds. Exports are
+ saved in plugins/MineGeo/exports/export-…/. The
+ manifest appears when export finishes. Upload sends the latest
+ completed export; it does not capture the world.
+
Put the JAR in plugins/ and restart. Set website and server-name
- in plugins/MineGeo/config.yml. Permission: minegeo.admin.
+ in plugins/MineGeo/config.yml, then run /minegeo reload. HTTP LAN
+ addresses are supported for testing. Permission: minegeo.admin.
diff --git a/app/layout.tsx b/app/layout.tsx
index 45d02f2..5254f51 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
+import "./block-theme.css";
export const metadata: Metadata = {
title: {
default: "MineGeo — Get lost in a familiar world",
diff --git a/app/page.tsx b/app/page.tsx
index 8ac0125..6206057 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -2,190 +2,246 @@ import Link from "next/link";
import Image from "next/image";
import {
ArrowRight,
- ArrowUpRight,
Compass,
- Cuboid,
- Map,
MapPin,
- Move,
Timer,
+ Blocks,
+ Upload,
} from "lucide-react";
-import { Header, Footer, EmptyState } from "@/components/Shell";
+import { Header, Footer } from "@/components/Shell";
import { MapCard } from "@/components/MapCard";
import { catalog } from "@/lib/catalog";
export const dynamic = "force-dynamic";
export default async function Home() {
const maps = await catalog();
- const serverCount = new Set(maps.map((m) => m.server_id)).size;
- const locations = maps.reduce((n, m) => n + m.panorama_count, 0);
+ const servers = new Set(maps.map((map) => map.server_id)).size;
+ const locations = maps.reduce((total, map) => total + map.panorama_count, 0);
return (
<>
-
+
-
- MINECRAFT WORLDS. REAL EXPLORATION.
+ MINECRAFT · EXPLORE · GUESS
- Get lost in a
- familiar world.
+ Guess where you are
+
+ in a blocky world.
- Somewhere between the blocks, there’s a place you know.
- Look around. Trust your instincts. Make
- your guess.
+ Drop into a random corner of a Minecraft world.
+ Look around, spot the landmarks, and
+ place your pin
+ before the clock runs out.
- Find your next world
-
-
- How it works
+ Start exploring
+
3 difficulty levels
-
- 360° panoramas
-
-
- No roads. No moving.
-
-
- Just you and the blocks.
+
+
+
+
+
+ Five rounds. No roads. Just your instincts.
-
-
- YOUR NEXT ADVENTURE IS OUT THERE
-
-
- OVERWORLD
- X ? · Z ?
-
+
-
-
+
+
N
- ONE WORLD. COUNTLESS POSSIBILITIES.
+
+ X: ??? Z: ???
+ Overworld
-
+
-
+
-
{serverCount}
-
COMMUNITY SERVERS
+
+
+ {servers.toLocaleString()}
+ COMMUNITY SERVERS
+
-
{maps.length}
-
WORLDS TO EXPLORE
+
+
+ {maps.length.toLocaleString()}
+ PUBLISHED MAPS
+
-
{locations}
-
PLACES TO GET LOST
+
+
+ {locations.toLocaleString()}
+ PLACES TO DISCOVER
+
-
-
- Built by explorers.
-
- For explorers.
+
+
+
+ 5 rounds
+ EVERY EXPEDITION
+
- PICK A WORLD. DROP A PIN.
-
Where to today?
+ COMMUNITY MAPS
+ Where will you go today?
- All worlds
+ All {maps.length} maps
{maps.length ? (
- {maps.slice(0, 3).map((m, i) => (
-
+ {maps.slice(0, 4).map((map, index) => (
+
))}
) : (
-
- The catalog is waiting for its first approved community map.
- Capture a world with our mod or server plugin.
-
+
+
+
+ YOUR WORLD HERE
+
+
+
+ THE FIRST EXPEDITION STARTS WITH YOU
+
+
Put your server on the map.
+
+ No community maps have been published yet. Export your world
+ with the server plugin, or capture five locations with the
+ Fabric mod. Preview your map and submit it for review.
+
+
+
Get the capture tools
+
+
+
)}
-
-
-
A NEW WAY TO SEE MINECRAFT
-
- Every block
-
- tells a story.
-
-
- That mountain. That oddly placed tower. That very suspicious dirt
- hut. You’ve been here before… right?
-
-
- Learn how to play
+
+
+ HOW TO PLAY
+ Know your blocks?
+
+
+ 01
+
+
Pick your world
+
Choose a community server, map, and difficulty.
+
+
+
+ 02
+
+
Look for the clues
+
+ Rotate the panorama. Find builds, biomes, and landmarks.
+
+
+
+
+ 03
+
+
Make your guess
+
Drop a pin on the map. Closer guesses earn more points.
+
+
+
+
+ Read the field guide
-
-
-
- 01
-
- Take a look around
- Find clues in a full 360° view of a Minecraft world.
-
-
- 02
-
- Trust your coordinates
- Open the map and pin the spot where you think you are.
-
-
- 03
-
- Beat the clock
- Five rounds. One world. How close can you get?
-
-
+
+
+ CHOOSE YOUR CHALLENGE
+ One world. Three ways.
+
+ {[
+ {
+ name: "Easy",
+ time: "180 seconds",
+ detail: "Take your time. Rotate and zoom.",
+ color: "green",
+ },
+ {
+ name: "Normal",
+ time: "90 seconds",
+ detail: "Trust your eye. Rotate and zoom.",
+ color: "gold",
+ },
+ {
+ name: "Hard",
+ time: "30 seconds",
+ detail: "Think fast. No zoom allowed.",
+ color: "red",
+ },
+ ].map((mode) => (
+
+
+
+
+ {mode.name}
+
+
+ {mode.time}
+
+
+
{mode.detail}
+
+
+
+ ))}
+
+
+ Up to 5,000 points per round. How close can you get?
+
+
+
-
YOUR SERVER DESERVES TO BE EXPLORED
-
Put your world on the map.
-
- Bring your builds, your landscapes, and your community to MineGeo.
-
+
BUILT BY THE COMMUNITY
+
Your world deserves to be explored.
+
Share your builds and your favorite corners of Minecraft.
-
- Create a map
+
+ Create a map
diff --git a/artifacts-api-smoke.json b/artifacts-api-smoke.json
new file mode 100644
index 0000000..43d9d4b
--- /dev/null
+++ b/artifacts-api-smoke.json
@@ -0,0 +1,11 @@
+{
+ "passed": true,
+ "checks": [
+ "production homepage/catalog/downloads/assets",
+ "Java uploader to real Supabase Storage",
+ "private draft before rendered assets exist",
+ "guests denied private drafts",
+ "cancel queued processing",
+ "cancel unfinished upload without a queue row"
+ ]
+}
\ No newline at end of file
diff --git a/artifacts-plugin-regression.json b/artifacts-plugin-regression.json
new file mode 100644
index 0000000..51d71f3
--- /dev/null
+++ b/artifacts-plugin-regression.json
@@ -0,0 +1,16 @@
+{
+ "passed": true,
+ "checks": [
+ "missing export guidance",
+ "empty area failure",
+ "real Paper export without a player",
+ "shared schema validation",
+ "TUS interruption and byte-perfect resume",
+ "repeated upload is idempotent",
+ "cancellation preserves completed export"
+ ],
+ "blocks": 10112,
+ "chunks": 16,
+ "viewpoints": 5,
+ "directory": ".cache/plugin-regression-1791117070908"
+}
\ No newline at end of file
diff --git a/components/DraftEditor.tsx b/components/DraftEditor.tsx
index e1750fc..5bd8d67 100644
--- a/components/DraftEditor.tsx
+++ b/components/DraftEditor.tsx
@@ -9,6 +9,7 @@ type Draft = {
description: string;
state: string;
source: string;
+ rejection_reason: string | null;
map: string | null;
manifest: DatasetManifest;
previews: { id: string; image: string | null }[];
@@ -22,14 +23,27 @@ export function DraftEditor({ id }: { id: string }) {
[message, setMessage] = useState(""),
[busy, setBusy] = useState(false);
useEffect(() => {
- api
("datasets/" + id)
- .then((d) => {
- setDraft(d);
- setName(d.name);
- setDescription(d.description);
- setSelected(d.manifest.viewpoints.map((p) => p.id));
- })
- .catch((e) => setError(e.message));
+ let disposed = false;
+ let timer: ReturnType;
+ const load = () =>
+ api("datasets/" + id)
+ .then((d) => {
+ if (disposed) return;
+ setDraft(d);
+ setName(d.name);
+ setDescription(d.description);
+ setSelected(d.manifest.viewpoints.map((p) => p.id));
+ if (["uploading", "processing"].includes(d.state))
+ timer = setTimeout(load, 5000);
+ })
+ .catch((e) => {
+ if (!disposed) setError(e.message);
+ });
+ load();
+ return () => {
+ disposed = true;
+ clearTimeout(timer);
+ };
}, [id]);
async function save(submit = false) {
setBusy(true);
@@ -55,6 +69,11 @@ export function DraftEditor({ id }: { id: string }) {
setBusy(true);
try {
await api("datasets/" + id + "/cancel", {});
+ setDraft((d) =>
+ d
+ ? { ...d, state: "failed", rejection_reason: "Processing cancelled" }
+ : d,
+ );
setMessage("Processing cancelled.");
} catch (e) {
setError((e as Error).message);
@@ -70,6 +89,18 @@ export function DraftEditor({ id }: { id: string }) {
return (
<>
{draft.state}
+ {["uploading", "processing"].includes(draft.state) && (
+
+ {draft.state === "uploading"
+ ? "Waiting for the capture tool to finish uploading. Retry upload in the tool if the connection was interrupted."
+ : "Your map and panoramas are being rendered. This page updates automatically."}
+
+ )}
+ {draft.rejection_reason && (
+
+ {draft.rejection_reason}
+
+ )}
{draft.source === "plugin"
? "Server-generated views use vanilla terrain and simplified lighting. Entities, shaders, custom packs and dynamic sign text are omitted."
diff --git a/components/Shell.tsx b/components/Shell.tsx
index 5b6a073..4bdf777 100644
--- a/components/Shell.tsx
+++ b/components/Shell.tsx
@@ -17,12 +17,12 @@ export function Header() {
- Explore servers
- Create a map
- How to play
+ Play & maps
+ Downloads
+ Guide
- My expedition
+ My account
diff --git a/docs/server-plugin.md b/docs/server-plugin.md
new file mode 100644
index 0000000..c1f868a
--- /dev/null
+++ b/docs/server-plugin.md
@@ -0,0 +1,61 @@
+# Server plugin 0.1.1
+
+## Update and connect
+
+1. Stop the Minecraft server, replace its old MineGeo JAR with `public/tools/plugin/minegeo-plugin-1.21.11.jar`, and restart. Run `/version MineGeo` to verify 0.1.1.
+2. Open your MineGeo website dashboard, sign in, and generate a plugin token.
+3. In the Minecraft server console, configure the website and token. Console commands do not need the leading slash:
+
+```text
+minegeo website https://your-minegeo-domain.example
+minegeo link
+```
+
+For LAN testing the website may be `http://192.168.1.50:3000`; use your actual address. `localhost` means the computer running Minecraft, which may be different from the Mac running the website. HTTP is supported for testing; use HTTPS when transmitting contributor tokens over a public connection.
+
+## Export before uploading
+
+Choose a small, already generated area around your builds. Coordinates below are examples:
+
+```text
+minegeo area world -64 -64 64 64
+minegeo export My Overworld
+minegeo status
+```
+
+Wait for **Export complete**. The plugin reports the full path, for example:
+
+```text
+plugins/MineGeo/exports/export-1791117138143/
+ terrain.json.gz
+ manifest.json
+```
+
+Each attempt gets a separate folder. A manifest is created only after the complete export passes the local limits and has five safe viewpoints. If the area is unexplored, underwater, too large, or has too few safe positions, the console and status tell you why. No new terrain is generated by exporting.
+
+If no area has been selected, export uses a 512 × 512 area centered on the player issuing the command, or on world spawn for console commands. The configured maximum area size still applies.
+
+Then run:
+
+```text
+minegeo upload
+minegeo status
+```
+
+Upload sends the latest **completed** export. It does not generate an export. The selection survives server restarts through `plugins/MineGeo/latest-export.txt`. Older `plugins/MineGeo/export/manifest.json` exports are still recognized when no newer selection exists.
+
+## Finish on the website
+
+The worker renders the map and panoramas. Open the private draft in your dashboard; the page updates while processing. Inspect the result, choose at least five locations, and submit for moderation. Once a moderator approves it, players can select it in the catalog.
+
+## Retry, cancel, and configure
+
+- An interrupted transfer resumes with `minegeo upload`. Progress is saved in `.upload-state.json` beside the export. Already received exports are not submitted twice.
+- `minegeo cancel` cancels capture or stops upload at the next transfer boundary. Completed exports are kept. HTTP requests already in flight can take up to their timeout to stop.
+- `minegeo reload` reloads `config.yml` after you edit `website`, `server-name`, `chunks-per-tick`, or other settings.
+- `minegeo status` reports the active stage and the last completed export folder.
+- `minegeo help` lists commands.
+
+If processing fails on the website, read the draft's error. Fix the worker/setup or reduce the export area, then create and upload a new export. Keep the old folders until you no longer need them.
+
+Expected limits: Minecraft 1.21.11 / Java 21, Overworld only, five or more safe viewpoints, at most 2048 blocks per selected side and the worker's terrain/file-size limits. A large selection may need to be split into smaller maps.
diff --git a/docs/verification.md b/docs/verification.md
new file mode 100644
index 0000000..b407293
--- /dev/null
+++ b/docs/verification.md
@@ -0,0 +1,22 @@
+# Verification of the export/upload fixes
+
+The following checks passed on Windows with Java 21 and Minecraft/Paper 1.21.11:
+
+- A temporary Paper server with no connected player exported 16 chunks / 10,112 retained blocks and five valid surface viewpoints.
+- Upload without an export produced instructions instead of a missing-file exception.
+- An ungenerated area failed clearly, and a later export succeeded.
+- A simulated mid-transfer interruption resumed from the server's byte offset and reproduced the original SHA-256 checksum.
+- Retrying a completed upload did not create or finalize a duplicate dataset.
+- Cancellation preserved the pointer to the previous complete export.
+- The final Java uploader uploaded a real terrain file to the configured Supabase Storage project through the production Next.js API, then queued processing.
+- An unrendered draft could be opened without trying to sign nonexistent preview images.
+- Guests could not read that private draft.
+- Both an unfinished upload and queued processing could be cancelled.
+- Production homepage, catalog, downloads, local pixel font, example map, and both JAR URLs returned HTTP 200.
+- Both Gradle projects built; TypeScript, 14 Node tests, and the Next.js production build passed.
+
+The real Storage test caught two additional issues that a mock endpoint did not: Java's cleartext HTTP/2 upgrade was incompatible with the website, and signed TUS transfers require `/storage/v1/upload/resumable/sign` plus the public API key and scoped signature. Both were corrected and the real upload passed afterward.
+
+Reports: `artifacts-plugin-regression.json` and `artifacts-api-smoke.json`. Temporary test datasets were deleted and the temporary servers stopped.
+
+This pass did not launch a Minecraft client, use Playwright, or use the Computer plugin. The browser appearance, an actual Fabric capture session, Spigot loading, and a fresh plugin panorama rendering run on macOS still need runtime verification. The existing renderer has not been replaced; earlier renderer proof is recorded separately in `artifacts-renderer-proof.json`.
diff --git a/lib/api.ts b/lib/api.ts
index 5ff161f..9904bfa 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -294,7 +294,7 @@ export async function handle(request: NextRequest, path: string[]) {
.single(),
);
const endpoint = new URL(
- "/storage/v1/upload/resumable",
+ "/storage/v1/upload/resumable/sign",
process.env.NEXT_PUBLIC_SUPABASE_URL,
);
endpoint.hostname = endpoint.hostname.replace(
@@ -315,6 +315,7 @@ export async function handle(request: NextRequest, path: string[]) {
path: a.path,
objectName,
token: signed.token,
+ apiKey: process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
endpoint: endpoint.toString(),
};
}),
@@ -368,7 +369,9 @@ export async function handle(request: NextRequest, path: string[]) {
previews: await Promise.all(
manifest.viewpoints.map(async (p: { id: string }) => ({
id: p.id,
- image: await mediaUrl(`${d.id}/${p.id}/preview.webp`),
+ image: d.map_path
+ ? await mediaUrl(`${d.id}/${p.id}/preview.webp`)
+ : null,
})),
),
});
@@ -441,20 +444,28 @@ export async function handle(request: NextRequest, path: string[]) {
return NextResponse.json({ ok: true });
}
if (method === "POST" && path[2] === "cancel") {
+ if (!["uploading", "processing"].includes(d.state))
+ throw new Error("This dataset is not uploading or processing");
+ const cancelled = await client
+ .from("upload_jobs")
+ .update({ state: "cancelled" })
+ .eq("dataset_id", d.id)
+ .in("state", ["queued", "running"])
+ .select("id")
+ .maybeSingle();
+ if (cancelled.error) throw new Error(cancelled.error.message);
required(
await client
- .from("upload_jobs")
- .update({ state: "cancelled" })
- .eq("dataset_id", d.id)
- .in("state", ["queued", "running"])
+ .from("datasets")
+ .update({
+ state: "failed",
+ rejection_reason: "Processing cancelled",
+ })
+ .eq("id", d.id)
+ .in("state", ["uploading", "processing"])
.select("id")
.single(),
);
- await client
- .from("datasets")
- .update({ state: "failed", rejection_reason: "Processing cancelled" })
- .eq("id", d.id)
- .in("state", ["uploading", "processing"]);
return NextResponse.json({ ok: true });
}
}
diff --git a/lib/catalog.ts b/lib/catalog.ts
index 8ef2d10..f3174f2 100644
--- a/lib/catalog.ts
+++ b/lib/catalog.ts
@@ -40,7 +40,8 @@ export async function catalog() {
return Promise.all(
maps.map(async (m) => ({
...m,
- image: (await mediaUrl(m.cover_path)) ?? undefined,
+ image: (await mediaUrl(m.map_path || m.cover_path)) ?? undefined,
+ mapImage: (await mediaUrl(m.map_path)) ?? undefined,
server: servers.find((s) => s.id === m.server_id),
})),
);
diff --git a/minecraft/build.gradle b/minecraft/build.gradle
index b1c32f7..7891281 100644
--- a/minecraft/build.gradle
+++ b/minecraft/build.gradle
@@ -1 +1 @@
-allprojects { group = 'dev.minegeo'; version = '0.1.0'; repositories { mavenCentral() } }
+allprojects { group = 'dev.minegeo'; version = '0.1.1'; repositories { mavenCentral() } }
diff --git a/minecraft/fabric/src/main/java/dev/minegeo/fabric/CaptureScreen.java b/minecraft/fabric/src/main/java/dev/minegeo/fabric/CaptureScreen.java
index 3f0428d..a0e01ba 100644
--- a/minecraft/fabric/src/main/java/dev/minegeo/fabric/CaptureScreen.java
+++ b/minecraft/fabric/src/main/java/dev/minegeo/fabric/CaptureScreen.java
@@ -17,6 +17,6 @@ public final class CaptureScreen extends Screen {
addDrawableChild(ButtonWidget.builder(Text.literal("New map at this location"),b->{mod.newMap();init(width,height);}).dimensions(x,182,260,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Close"),b->close()).dimensions(x,210,260,20).build());
}
- @Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Website (HTTPS outside localhost)",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,242,0xffffffff);int y=264;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}}
+ @Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Website URL (HTTP for LAN testing)",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,242,0xffffffff);int y=264;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}}
@Override public boolean shouldPause(){return false;}
}
diff --git a/minecraft/fabric/src/main/java/dev/minegeo/fabric/MineGeoClient.java b/minecraft/fabric/src/main/java/dev/minegeo/fabric/MineGeoClient.java
index e8e4a8b..e62a45f 100644
--- a/minecraft/fabric/src/main/java/dev/minegeo/fabric/MineGeoClient.java
+++ b/minecraft/fabric/src/main/java/dev/minegeo/fabric/MineGeoClient.java
@@ -17,7 +17,7 @@ public final class MineGeoClient implements ClientModInitializer {
public static MineGeoClient INSTANCE;
public final ExecutorService background=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"MineGeo upload");t.setDaemon(true);return t;});
public final Gson gson=new GsonBuilder().setPrettyPrinting().create();
- public String website="http://localhost:3000",token="",status="Explore normally to collect surface coverage.";
+ public volatile String website="http://localhost:3000",token="",status="Explore normally to collect surface coverage.";
public volatile boolean uploading=false;public SurfaceDataset dataset;private SurfaceDataset.Point pending;
private Path config;
@Override public void onInitializeClient(){INSTANCE=this;config=MinecraftClient.getInstance().runDirectory.toPath().resolve("config/minegeo.json");try{if(Files.exists(config)){var j=JsonParser.parseString(Files.readString(config)).getAsJsonObject();website=j.get("website").getAsString();token=j.get("token").getAsString();}}catch(Exception e){message("Could not read MineGeo configuration");}
@@ -31,5 +31,5 @@ public final class MineGeoClient implements ClientModInitializer {
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 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 upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;if(selected==null||selected.points.size()<5){message("Capture at least five locations before uploading.");return;}uploading=true;background.execute(()->{try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(website,token).upload(selected.directory);MinecraftClient.getInstance().execute(()->message("Upload received. Preview and submit the private draft in your dashboard."));}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});}
+ public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;if(selected==null||selected.points.size()<5){message("Capture at least five locations before uploading.");return;}uploading=true;background.execute(()->{try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(website,token).upload(selected.directory, progress -> status=progress, () -> false);MinecraftClient.getInstance().execute(()->message("Upload received. Preview and submit the private draft in your dashboard."));}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});}
}
diff --git a/minecraft/plugin/src/main/java/dev/minegeo/plugin/MineGeoPlugin.java b/minecraft/plugin/src/main/java/dev/minegeo/plugin/MineGeoPlugin.java
index 58edcee..7804004 100644
--- a/minecraft/plugin/src/main/java/dev/minegeo/plugin/MineGeoPlugin.java
+++ b/minecraft/plugin/src/main/java/dev/minegeo/plugin/MineGeoPlugin.java
@@ -1,73 +1,144 @@
package dev.minegeo.plugin;
import com.google.gson.*;
+import com.google.gson.stream.JsonWriter;
import org.bukkit.*;
import org.bukkit.command.*;
+import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitRunnable;
import java.io.*;
+import java.nio.charset.StandardCharsets;
import java.nio.file.*;
+import java.security.MessageDigest;
import java.util.*;
-import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+import java.util.logging.Level;
import java.util.zip.GZIPOutputStream;
public final class MineGeoPlugin extends JavaPlugin {
private World selectedWorld;
private int minX=-256,minZ=-256,maxX=256,maxZ=256;
private final AtomicBoolean cancelled=new AtomicBoolean();
- private volatile String status="Idle";
+ private volatile String status="Idle — /minegeo export to begin";
private volatile boolean busy;
- private final Gson gson=new Gson();
- @Override public void onEnable(){saveDefaultConfig();Objects.requireNonNull(getCommand("minegeo")).setExecutor(this);getLogger().info("MineGeo ready. /minegeo area ");}
+ private final Gson gson=new GsonBuilder().setPrettyPrinting().create();
+ private volatile Path latest;
+ @Override public void onEnable(){
+ saveDefaultConfig();Objects.requireNonNull(getCommand("minegeo")).setExecutor(this);
+ try {
+ Files.createDirectories(getDataFolder().toPath().resolve("exports"));
+ Path root=getDataFolder().toPath().toAbsolutePath().normalize(),pointer=root.resolve("latest-export.txt");
+ if(Files.exists(pointer)) {Path candidate=root.resolve(Files.readString(pointer).trim()).normalize();if(candidate.startsWith(root)&&Files.isRegularFile(candidate.resolve("manifest.json")))latest=candidate;}
+ Path legacy=root.resolve("export");if(latest==null&&Files.isRegularFile(legacy.resolve("manifest.json")))latest=legacy;
+ }catch(IOException ex){getLogger().log(Level.WARNING,"Cannot prepare export folder",ex);}
+ getLogger().info("MineGeo ready. /minegeo help — exports: "+getDataFolder().toPath().resolve("exports").toAbsolutePath());
+ }
@Override public void onDisable(){cancelled.set(true);}
@Override public boolean onCommand(CommandSender sender,Command command,String label,String[] args){
if(!sender.hasPermission("minegeo.admin")){sender.sendMessage("MineGeo: administrator permission required.");return true;}
try {
- if(args.length==0){sender.sendMessage("MineGeo: area, export, status, cancel, link, upload");return true;}
- switch(args[0].toLowerCase(Locale.ROOT)){
- case "area" -> { if(busy)throw new IllegalArgumentException("Cancel the current export before changing the area.");if(args.length!=6)throw new IllegalArgumentException("area ");World w=Bukkit.getWorld(args[1]);if(w==null||w.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Select a loaded Overworld.");int ax=Integer.parseInt(args[2]),az=Integer.parseInt(args[3]),bx=Integer.parseInt(args[4]),bz=Integer.parseInt(args[5]);int cap=Math.min(2048,getConfig().getInt("max-area-size",2048));if(bx<=ax||bz<=az||bx-ax>cap||bz-az>cap)throw new IllegalArgumentException("Invalid bounds; maximum "+cap+" blocks per side.");selectedWorld=w;minX=ax;minZ=az;maxX=bx;maxZ=bz;sender.sendMessage("MineGeo area selected: "+w.getName()+" "+ax+","+az+" to "+bx+","+bz);}
- case "status" -> sender.sendMessage("MineGeo: "+status);
- case "cancel" -> {cancelled.set(true);sender.sendMessage("MineGeo cancellation requested.");}
+ if(args.length==0||args[0].equalsIgnoreCase("help")) {sender.sendMessage(new String[]{"MineGeo commands:","/minegeo website ","/minegeo link ","/minegeo area ","/minegeo export — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo status | cancel | reload"});return true;}
+ switch(args[0].toLowerCase(Locale.ROOT)) {
+ case "area" -> {
+ requireIdle();if(args.length!=6)throw new IllegalArgumentException("Usage: /minegeo area ");
+ World world=Bukkit.getWorld(args[1]);if(world==null||world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Select a loaded Overworld.");
+ int ax=Integer.parseInt(args[2]),az=Integer.parseInt(args[3]),bx=Integer.parseInt(args[4]),bz=Integer.parseInt(args[5]);int cap=Math.max(16,Math.min(2048,getConfig().getInt("max-area-size",2048)));
+ if(bx<=ax||bz<=az||(long)bx-ax>cap||(long)bz-az>cap||Math.abs((long)ax)>30000000||Math.abs((long)az)>30000000||Math.abs((long)bx)>30000000||Math.abs((long)bz)>30000000)throw new IllegalArgumentException("Invalid bounds; maximum "+cap+" blocks per side, within the world border.");
+ selectedWorld=world;minX=ax;minZ=az;maxX=bx;maxZ=bz;sender.sendMessage("MineGeo area selected: "+world.getName()+" "+ax+","+az+" to "+bx+","+bz);
+ }
+ case "status" -> {sender.sendMessage("MineGeo: "+status);if(latest!=null)sender.sendMessage("Latest complete export: "+latest.toAbsolutePath());}
+ case "cancel" -> {cancelled.set(true);sender.sendMessage(busy?"MineGeo cancellation requested. Completed exports are kept.":"MineGeo: no operation is running.");}
+ case "reload" -> {requireIdle();reloadConfig();sender.sendMessage("MineGeo configuration reloaded.");}
+ case "website" -> {requireIdle();if(args.length!=2)throw new IllegalArgumentException("Usage: /minegeo website ");new UploadClient(args[1],"");getConfig().set("website",args[1]);saveConfig();sender.sendMessage("MineGeo website saved: "+args[1]);}
+ case "link" -> {requireIdle();if(args.length!=2||args[1].isBlank())throw new IllegalArgumentException("Usage: /minegeo link ");getConfig().set("token",args[1]);saveConfig();sender.sendMessage("MineGeo contributor token saved.");}
case "export" -> export(sender,args.length>1?String.join(" ",Arrays.copyOfRange(args,1,args.length)):"Overworld Explorer");
- case "link" -> { if(args.length!=2)throw new IllegalArgumentException("link ");getConfig().set("token",args[1]);saveConfig();sender.sendMessage("MineGeo contributor token saved. Restrict access to config.yml.");}
- case "upload" -> {if(busy)throw new IllegalArgumentException("Wait for the current operation.");busy=true;Bukkit.getScheduler().runTaskAsynchronously(this,()->{try{status="Uploading";new dev.minegeo.plugin.UploadClient(getConfig().getString("website"),getConfig().getString("token")).upload(getDataFolder().toPath().resolve("export"));status="Upload complete; review the private draft on MineGeo.";}catch(Exception ex){status="Upload failed: "+ex.getMessage();getLogger().warning(status);}finally{busy=false;}});sender.sendMessage("MineGeo upload started.");}
- default -> throw new IllegalArgumentException("Unknown command.");
+ case "upload" -> upload(sender);
+ default -> throw new IllegalArgumentException("Unknown command. Use /minegeo help.");
}
- }catch(Exception ex){sender.sendMessage("MineGeo: "+ex.getMessage());}
- return true;
+ }catch(Exception ex){sender.sendMessage("MineGeo: "+ex.getMessage());}return true;
+ }
+ private void requireIdle(){if(busy)throw new IllegalArgumentException("An operation is running. Use /minegeo status or /minegeo cancel.");}
+ private void report(CommandSender sender,String message){status=message;getLogger().info(message);if(sender instanceof Player&&isEnabled())Bukkit.getScheduler().runTask(this,()->sender.sendMessage("MineGeo: "+message));}
+ private void fail(CommandSender sender,String phase,Exception ex){report(sender,cancelled.get()?"Cancelled. Completed exports are kept.":phase+" failed: "+ex.getMessage());if(!cancelled.get())getLogger().log(Level.WARNING,phase+" failure",ex);}
+ private void upload(CommandSender sender)throws Exception {
+ requireIdle();if(latest==null||!Files.isRegularFile(latest.resolve("manifest.json")))throw new IOException("No completed export. Run /minegeo export My World, wait for 'Export complete', then /minegeo upload. Files: "+getDataFolder().toPath().resolve("exports").toAbsolutePath());
+ String website=getConfig().getString("website"),token=getConfig().getString("token");if(token==null||token.isBlank())throw new IOException("Create a token in the website dashboard, then /minegeo link .");
+ UploadClient client=new UploadClient(website,token);Path directory=latest;busy=true;cancelled.set(false);status="Uploading "+directory.getFileName();
+ Bukkit.getScheduler().runTaskAsynchronously(this,()->{try{client.upload(directory,message->status=message,cancelled::get);report(sender,"Upload complete. Open "+website+"/dashboard to preview the private draft after processing.");}catch(Exception ex){fail(sender,"Upload",ex);}finally{busy=false;}});
+ sender.sendMessage("MineGeo upload started. /minegeo status shows progress; retry /minegeo upload after a connection failure.");
}
private record Captured(int cx,int cz,ChunkSnapshot snapshot){}
- private void export(CommandSender sender,String name)throws IOException{
- if(busy)throw new IllegalArgumentException("An export or upload is already running.");
- World world=selectedWorld!=null?selectedWorld:Bukkit.getWorlds().getFirst();
- if(world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Overworld only.");
- busy=true;cancelled.set(false);List coords=new ArrayList<>();
- for(int cz=Math.floorDiv(minZ,16);cz<=Math.floorDiv(maxZ-1,16);cz++)for(int cx=Math.floorDiv(minX,16);cx<=Math.floorDiv(maxX-1,16);cx++)if(world.isChunkGenerated(cx,cz))coords.add(new int[]{cx,cz});
- if(coords.isEmpty()){busy=false;throw new IllegalArgumentException("No generated chunks in the selected area.");}
- Path out=getDataFolder().toPath().resolve("export");Files.createDirectories(out);
- List captured=new ArrayList<>();int lower=world.getMinHeight(),upper=world.getMaxHeight();
- sender.sendMessage("MineGeo snapshotting "+coords.size()+" generated chunks. No terrain will be generated.");
- new BukkitRunnable(){int index;
+ private record Area(int minX,int minZ,int maxX,int maxZ){}
+ private void export(CommandSender sender,String name)throws IOException {
+ requireIdle();if(name.length()<3||name.length()>80)throw new IllegalArgumentException("Map name must contain 3–80 characters.");
+ String serverName=getConfig().getString("server-name","My Minecraft server");if(serverName.length()<2||serverName.length()>80)throw new IllegalArgumentException("server-name in config.yml must contain 2–80 characters.");
+ World world=selectedWorld!=null?selectedWorld:sender instanceof Player p?p.getWorld():Bukkit.getWorlds().stream().filter(w->w.getEnvironment()==World.Environment.NORMAL).findFirst().orElseThrow();
+ if(world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Overworld only. Select it with /minegeo area.");
+ Location center=sender instanceof Player p?p.getLocation():world.getSpawnLocation();
+ int half=Math.max(8,Math.min(256,getConfig().getInt("max-area-size",2048)/2));
+ Area area=selectedWorld==null?new Area(center.getBlockX()-half,center.getBlockZ()-half,center.getBlockX()+half,center.getBlockZ()+half):new Area(minX,minZ,maxX,maxZ);
+ Path out=getDataFolder().toPath().resolve("exports").resolve("export-"+System.currentTimeMillis());Files.createDirectories(out);
+ int lower=world.getMinHeight(),upper=world.getMaxHeight(),desired=Math.min(100,Math.max(5,getConfig().getInt("viewpoints",12))),batch=Math.max(1,Math.min(4,getConfig().getInt("chunks-per-tick",1)));
+ ArrayBlockingQueue queue=new ArrayBlockingQueue<>(4);AtomicBoolean done=new AtomicBoolean();AtomicReference failure=new AtomicReference<>();
+ busy=true;cancelled.set(false);status="Export started: "+out.toAbsolutePath();sender.sendMessage("MineGeo: "+status);
+ Bukkit.getScheduler().runTaskAsynchronously(this,()->{
+ try{encode(queue,done,failure,out,name,serverName,area,lower,upper,desired);Path root=getDataFolder().toPath(),pointer=root.resolve("latest-export.txt.tmp");Files.writeString(pointer,root.relativize(out).toString());Files.move(pointer,root.resolve("latest-export.txt"),StandardCopyOption.REPLACE_EXISTING);latest=out;report(sender,"Export complete: "+out.toAbsolutePath()+". Run /minegeo upload.");}
+ catch(Exception ex){failure.set(ex);fail(sender,"Export",ex);}finally{done.set(true);busy=false;}
+ });
+ new BukkitRunnable(){int cx=Math.floorDiv(area.minX,16),cz=Math.floorDiv(area.minZ,16),scanned;final int firstX=cx,lastX=Math.floorDiv(area.maxX-1,16),lastZ=Math.floorDiv(area.maxZ-1,16),total=(lastX-firstX+1)*(lastZ-cz+1);
@Override public void run(){try{
- if(cancelled.get()){status="Cancelled";busy=false;cancel();return;}
- int batch=Math.max(1,Math.min(4,getConfig().getInt("chunks-per-tick",1)));
- for(int n=0;n=coords.size()){cancel();status="Encoding terrain";Bukkit.getScheduler().runTaskAsynchronously(MineGeoPlugin.this,()->encode(captured,out,name,lower,upper));}
- }catch(Exception ex){cancel();busy=false;status="Export failed: "+ex.getMessage();getLogger().warning(status);}}
+ if(cancelled.get()||failure.get()!=null||done.get()){done.set(true);cancel();return;}
+ for(int n=0;n0;n++){
+ if(world.isChunkGenerated(cx,cz)){boolean wasLoaded=world.isChunkLoaded(cx,cz);if(world.loadChunk(cx,cz,false)){try{queue.add(new Captured(cx,cz,world.getChunkAt(cx,cz).getChunkSnapshot(true,true,false)));}finally{if(!wasLoaded)world.unloadChunkRequest(cx,cz);}}}
+ scanned++;if(++cx>lastX){cx=firstX;cz++;}
+ }
+ status="Exporting chunks "+scanned+"/"+total+" — "+out.getFileName();if(cz>lastZ){done.set(true);cancel();}
+ }catch(Exception ex){failure.set(ex);done.set(true);cancel();}}
}.runTaskTimer(this,1,1);
}
- private void encode(List chunks,Path out,String name,int lower,int upper){
- try{JsonObject bounds=new JsonObject();bounds.addProperty("minX",minX);bounds.addProperty("minZ",minZ);bounds.addProperty("maxX",maxX);bounds.addProperty("maxZ",maxZ);
- JsonObject terrain=new JsonObject();terrain.add("bounds",bounds);JsonArray blocks=new JsonArray(),coverage=new JsonArray(),points=new JsonArray();
- List candidates=new ArrayList<>();
- for(Captured c:chunks){if(cancelled.get())throw new IOException("Cancelled");JsonArray cv=new JsonArray();cv.add(c.cx);cv.add(c.cz);coverage.add(cv);
- for(int z=0;z<16;z++)for(int x=0;x<16;x++){int wx=c.cx*16+x,wz=c.cz*16+z;if(wx=maxX||wz=maxZ)continue;int top=c.snapshot.getHighestBlockYAt(x,z);for(int y=Math.max(lower,top-24);y<=Math.min(upper-1,top+1);y++){var bd=c.snapshot.getBlockData(x,y,z);if(bd.getMaterial().isAir())continue;JsonArray block=new JsonArray();block.add(wx);block.add(y);block.add(wz);block.add(bd.getAsString());block.add(c.snapshot.getBiome(x,y,z).getKey().toString());blocks.add(block);}if(x==8&&z==8){int y=top;while(y>lower&&!c.snapshot.getBlockData(x,y,z).getMaterial().isSolid())y--;Material ground=c.snapshot.getBlockData(x,y,z).getMaterial();if(ground.isSolid()&&!ground.name().contains("LEAVES")&&c.snapshot.getBlockData(x,y+1,z).getMaterial().isAir()&&c.snapshot.getBlockData(x,y+2,z).getMaterial().isAir())candidates.add(new int[]{wx,y+1,wz});}}
+ private void encode(BlockingQueue queue,AtomicBoolean done,AtomicReference failure,Path out,String name,String serverName,Area area,int lower,int upper,int desired)throws Exception {
+ JsonObject bounds=new JsonObject();bounds.addProperty("minX",area.minX);bounds.addProperty("minZ",area.minZ);bounds.addProperty("maxX",area.maxX);bounds.addProperty("maxZ",area.maxZ);
+ JsonArray coverage=new JsonArray(),points=new JsonArray();List candidates=new ArrayList<>();long blocks=0;Path terrain=out.resolve("terrain.json.gz");
+ try(JsonWriter writer=new JsonWriter(new BufferedWriter(new OutputStreamWriter(new LimitedStream(new GZIPOutputStream(Files.newOutputStream(terrain))),StandardCharsets.UTF_8)))){
+ writer.beginObject().name("bounds");gson.toJson(bounds,writer);writer.name("blocks").beginArray();
+ while(!done.get()||!queue.isEmpty()){
+ if(cancelled.get())throw new IOException("Cancelled");if(failure.get()!=null)throw failure.get();Captured c=queue.poll(250,TimeUnit.MILLISECONDS);if(c==null)continue;
+ JsonArray cv=new JsonArray();cv.add(c.cx);cv.add(c.cz);coverage.add(cv);
+ for(int z=0;z<16;z++)for(int x=0;x<16;x++){
+ int wx=c.cx*16+x,wz=c.cz*16+z;if(wx=area.maxX||wz=area.maxZ)continue;
+ int top=Math.min(upper-1,Math.max(lower,c.snapshot.getHighestBlockYAt(x,z)));
+ // Skip buried opaque blocks to keep surface exports within the renderer budget.
+ for(int y=Math.max(lower,top-24);y<=top;y++){
+ Material type=c.snapshot.getBlockType(x,y,z);if(type.isAir())continue;
+ if(type.isOccluding()&&x>0&&x<15&&z>0&&z<15&&y>lower&&y+15000000)throw new IOException("Export exceeds the 5-million-block renderer limit. Select a smaller area.");
+ writer.beginArray().value(wx).value(y).value(wz).value(c.snapshot.getBlockData(x,y,z).getAsString()).value(c.snapshot.getBiome(x,y,z).getKey().toString()).endArray();
+ }
+ if(x%4==2&&z%4==2){int y=top;while(y>lower&&c.snapshot.getBlockType(x,y,z).isAir())y--;Material ground=c.snapshot.getBlockType(x,y,z);if(y+2 chosen=new ArrayList<>();chosen.add(candidates.remove(candidates.size()/2));
+ while(chosen.size()bestDistance){bestDistance=distance;best=i;}}chosen.add(candidates.remove(best));
+ }
+ for(int i=0;i50L*1024*1024)throw new IOException("Compressed export exceeds 50 MiB. Select a smaller area.");
+ JsonObject manifest=new JsonObject();manifest.addProperty("format","minegeo-dataset/v1");manifest.addProperty("minecraftVersion","1.21.11");manifest.addProperty("dimension","minecraft:overworld");manifest.addProperty("source","plugin");manifest.addProperty("name",name);manifest.addProperty("description","Vanilla surface terrain exported from "+serverName);manifest.addProperty("serverName",serverName);manifest.addProperty("north","-z");manifest.add("bounds",bounds);manifest.add("viewpoints",points);
+ JsonArray assets=new JsonArray();JsonObject asset=new JsonObject();asset.addProperty("path","terrain.json.gz");asset.addProperty("bytes",Files.size(terrain));MessageDigest digest=MessageDigest.getInstance("SHA-256");try(InputStream input=Files.newInputStream(terrain)){byte[] buffer=new byte[65536];int read;while((read=input.read(buffer))!=-1)digest.update(buffer,0,read);}asset.addProperty("sha256",HexFormat.of().formatHex(digest.digest()));assets.add(asset);manifest.add("assets",assets);
+ // The manifest is the completion marker. Partial folders never become upload candidates.
+ if(cancelled.get())throw new IOException("Cancelled");Files.writeString(out.resolve("manifest.json.tmp"),gson.toJson(manifest));Files.move(out.resolve("manifest.json.tmp"),out.resolve("manifest.json"),StandardCopyOption.REPLACE_EXISTING);
+ }
+ private static final class LimitedStream extends FilterOutputStream {
+ private long bytes;LimitedStream(OutputStream out){super(out);}
+ @Override public void write(int b)throws IOException{limit(1);out.write(b);}
+ @Override public void write(byte[] b,int off,int len)throws IOException{limit(len);out.write(b,off,len);}
+ private void limit(int n)throws IOException{bytes+=n;if(bytes>240L*1024*1024)throw new IOException("Terrain exceeds the renderer's uncompressed size limit. Select a smaller area.");}
}
}
diff --git a/minecraft/plugin/src/main/java/dev/minegeo/plugin/UploadClient.java b/minecraft/plugin/src/main/java/dev/minegeo/plugin/UploadClient.java
index 2c878fd..e019155 100644
--- a/minecraft/plugin/src/main/java/dev/minegeo/plugin/UploadClient.java
+++ b/minecraft/plugin/src/main/java/dev/minegeo/plugin/UploadClient.java
@@ -6,30 +6,77 @@ import java.nio.file.*;
import java.io.*;
import java.util.*;
import java.time.Duration;
+import java.security.MessageDigest;
+import java.util.function.*;
+import java.nio.charset.StandardCharsets;
-/** TUS resumable upload client shared by plugin and Fabric build. */
+/** Shared TUS client. State is saved beside each completed local dataset. */
public final class UploadClient {
private final String website,token;
- private final HttpClient client=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build();
+ // Next's HTTP server does not support Java's cleartext HTTP/2 upgrade handshake.
+ private final HttpClient client=HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).connectTimeout(Duration.ofSeconds(30)).build();
private final Gson gson=new Gson();
- public UploadClient(String website,String token){if(website==null||(!website.startsWith("https://")&&!website.startsWith("http://localhost")&&!website.startsWith("http://127.0.0.1")))throw new IllegalArgumentException("Use HTTPS or local development URL");this.website=website.replaceAll("/$","");this.token=token;}
- private JsonObject api(String path,String body)throws Exception{var request=HttpRequest.newBuilder(URI.create(website+"/api/v1"+path)).timeout(Duration.ofMinutes(2)).header("Authorization","Bearer "+token).header("Content-Type","application/json").POST(HttpRequest.BodyPublishers.ofString(body)).build();var response=client.send(request,HttpResponse.BodyHandlers.ofString());if(response.statusCode()>=400)throw new IOException("MineGeo API "+response.statusCode()+": "+response.body());return JsonParser.parseString(response.body()).getAsJsonObject();}
- public JsonObject beginPairing()throws Exception{return api("/pairing","{}");}
- public JsonObject pollPairing(String code)throws Exception{return api("/pairing/poll","{\"deviceCode\":\""+code+"\"}");}
- public void upload(Path directory)throws Exception{
- if(token==null||token.isBlank())throw new IOException("Link a contributor account first.");
- JsonObject manifest=JsonParser.parseString(Files.readString(directory.resolve("manifest.json"))).getAsJsonObject();
- Path ledger=directory.resolve(".upload-state.json");JsonObject state=Files.exists(ledger)?JsonParser.parseString(Files.readString(ledger)).getAsJsonObject():new JsonObject();
- String manifestHash=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(gson.toJson(manifest).getBytes(java.nio.charset.StandardCharsets.UTF_8)));
- if(!state.has("hash")||!state.get("hash").getAsString().equals(manifestHash)){state=new JsonObject();state.addProperty("hash",manifestHash);JsonObject request=new JsonObject();request.add("manifest",manifest);JsonObject created=api("/uploads",gson.toJson(request));state.addProperty("datasetId",created.get("datasetId").getAsString());Files.writeString(ledger,gson.toJson(state));}
- String datasetId=state.get("datasetId").getAsString();
- JsonObject request=new JsonObject();request.addProperty("datasetId",datasetId);JsonObject signed=api("/uploads/sign",gson.toJson(request));
- for(JsonElement elem:signed.getAsJsonArray("assets")){JsonObject asset=elem.getAsJsonObject();String name=asset.get("path").getAsString();Path file=directory.resolve(name).normalize();if(!file.startsWith(directory.normalize()))throw new IOException("Unsafe asset path");long length=Files.size(file);String signedToken=asset.get("token").getAsString();String endpoint=asset.get("endpoint").getAsString();String key="url:"+name;String uploadUrl=state.has(key)?state.get(key).getAsString():null;long offset=0;
- if(uploadUrl!=null){var head=client.send(HttpRequest.newBuilder(URI.create(uploadUrl)).timeout(Duration.ofSeconds(45)).header("Tus-Resumable","1.0.0").header("x-signature",signedToken).method("HEAD",HttpRequest.BodyPublishers.noBody()).build(),HttpResponse.BodyHandlers.discarding());if(head.statusCode()==200||head.statusCode()==204)offset=Long.parseLong(head.headers().firstValue("Upload-Offset").orElse("0"));else uploadUrl=null;}
- if(uploadUrl==null){String metadata="bucketName "+b64("minegeo-raw")+",objectName "+b64(asset.get("objectName").getAsString())+",contentType "+b64("application/octet-stream")+",cacheControl "+b64("3600");var created=client.send(HttpRequest.newBuilder(URI.create(endpoint)).timeout(Duration.ofSeconds(60)).header("Tus-Resumable","1.0.0").header("Upload-Length",Long.toString(length)).header("Upload-Metadata",metadata).header("x-signature",signedToken).POST(HttpRequest.BodyPublishers.noBody()).build(),HttpResponse.BodyHandlers.ofString());if(created.statusCode()!=201)throw new IOException("TUS create failed: "+created.statusCode()+" "+created.body());uploadUrl=URI.create(endpoint).resolve(created.headers().firstValue("Location").orElseThrow()).toString();state.addProperty(key,uploadUrl);Files.writeString(ledger,gson.toJson(state));}
- try(RandomAccessFile input=new RandomAccessFile(file.toFile(),"r")){while(offset=300){String detail=response.body();try{var json=JsonParser.parseString(detail).getAsJsonObject();if(json.has("error"))detail=json.get("error").getAsString();}catch(Exception ignored){}if(detail.length()>400)detail=detail.substring(0,400);throw new IOException("MineGeo API "+response.statusCode()+": "+detail+(response.statusCode()/100==3?". Set website to its final HTTPS address.":""));}
+ try{return JsonParser.parseString(response.body()).getAsJsonObject();}catch(Exception ex){throw new IOException("The website returned HTML or invalid JSON. Check the MineGeo website URL.");}
+ }
+ public JsonObject beginPairing()throws Exception{return api("/pairing","{}");}
+ public JsonObject pollPairing(String code)throws Exception{var body=new JsonObject();body.addProperty("deviceCode",code);return api("/pairing/poll",gson.toJson(body));}
+ public void upload(Path directory)throws Exception{upload(directory,message->{},()->false);}
+ private void checkCancelled(BooleanSupplier cancelled)throws IOException{if(cancelled.getAsBoolean()||Thread.currentThread().isInterrupted())throw new IOException("Upload cancelled. Run upload again to resume.");}
+ private void save(Path ledger,JsonObject state)throws IOException{Path tmp=ledger.resolveSibling(ledger.getFileName()+".tmp");Files.writeString(tmp,gson.toJson(state));Files.move(tmp,ledger,StandardCopyOption.REPLACE_EXISTING);}
+ private String hash(byte[] bytes)throws Exception{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}
+ public void upload(Path directory,Consumer progress,BooleanSupplier cancelled)throws Exception{
+ if(token.isBlank())throw new IOException("Link a contributor account first.");
+ directory=directory.toAbsolutePath().normalize();Path manifestFile=directory.resolve("manifest.json");
+ if(!Files.isRegularFile(manifestFile))throw new IOException("No completed manifest in "+directory+". Export first, wait for completion, then upload.");
+ JsonObject manifest=JsonParser.parseString(Files.readString(manifestFile)).getAsJsonObject();
+ progress.accept("Checking export files");
+ for(JsonElement element:manifest.getAsJsonArray("assets")){
+ checkCancelled(cancelled);var asset=element.getAsJsonObject();String name=asset.get("path").getAsString();Path file=directory.resolve(name).normalize();
+ if(!file.startsWith(directory)||!Files.isRegularFile(file)||!file.toRealPath().startsWith(directory.toRealPath()))throw new IOException("Missing or unsafe export asset: "+name);
+ if(Files.size(file)!=asset.get("bytes").getAsLong())throw new IOException("Export asset size changed: "+name+". Export again.");
+ MessageDigest digest=MessageDigest.getInstance("SHA-256");try(InputStream in=Files.newInputStream(file)){byte[] buffer=new byte[65536];int n;while((n=in.read(buffer))!=-1){checkCancelled(cancelled);digest.update(buffer,0,n);}}
+ if(!HexFormat.of().formatHex(digest.digest()).equals(asset.get("sha256").getAsString()))throw new IOException("Export asset checksum changed: "+name+". Export again.");
+ }
+ Path ledger=directory.resolve(".upload-state.json");JsonObject state=Files.exists(ledger)?JsonParser.parseString(Files.readString(ledger)).getAsJsonObject():new JsonObject();
+ String fingerprint=hash((website+"\n"+hash(token.getBytes(StandardCharsets.UTF_8))+"\n"+gson.toJson(manifest)).getBytes(StandardCharsets.UTF_8));
+ if(!state.has("hash")||!state.get("hash").getAsString().equals(fingerprint)){
+ state=new JsonObject();state.addProperty("hash",fingerprint);JsonObject body=new JsonObject();body.add("manifest",manifest);progress.accept("Creating private draft");checkCancelled(cancelled);
+ JsonObject created=api("/uploads",gson.toJson(body));state.addProperty("datasetId",created.get("datasetId").getAsString());save(ledger,state);
+ }
+ String datasetId=state.get("datasetId").getAsString();
+ JsonObject existing=api("/datasets/"+datasetId,null);String stage=existing.get("state").getAsString();
+ if(!stage.equals("uploading")){
+ if(stage.equals("failed"))throw new IOException("The website could not process this export. Check its dashboard error; create a new export after fixing the cause.");
+ progress.accept("Already uploaded — draft "+datasetId+" ("+stage+")");return;
+ }
+ JsonObject body=new JsonObject();body.addProperty("datasetId",datasetId);JsonObject signed=api("/uploads/sign",gson.toJson(body));int index=0,count=signed.getAsJsonArray("assets").size();
+ for(JsonElement elem:signed.getAsJsonArray("assets")){
+ checkCancelled(cancelled);index++;JsonObject asset=elem.getAsJsonObject();String name=asset.get("path").getAsString();Path file=directory.resolve(name).normalize();if(!file.startsWith(directory))throw new IOException("Unsafe asset path");
+ String publishableKey=asset.has("apiKey")?asset.get("apiKey").getAsString():"";long length=Files.size(file);String signedToken=asset.get("token").getAsString(),endpoint=asset.get("endpoint").getAsString(),key="url:"+name;
+ String uploadUrl=state.has(key)?state.get(key).getAsString():null;long offset=0;
+ if(uploadUrl!=null){var head=client.send(HttpRequest.newBuilder(URI.create(uploadUrl)).timeout(Duration.ofSeconds(45)).header("Tus-Resumable","1.0.0").header("x-signature",signedToken).header("apikey",publishableKey).method("HEAD",HttpRequest.BodyPublishers.noBody()).build(),HttpResponse.BodyHandlers.discarding());if(head.statusCode()==200||head.statusCode()==204)offset=Long.parseLong(head.headers().firstValue("Upload-Offset").orElseThrow());else if(head.statusCode()==404||head.statusCode()==410)uploadUrl=null;else throw new IOException("Upload resume failed (HTTP "+head.statusCode()+"). Retry upload.");}
+ if(uploadUrl==null){String metadata="bucketName "+b64("minegeo-raw")+",objectName "+b64(asset.get("objectName").getAsString())+",contentType "+b64("application/octet-stream")+",cacheControl "+b64("3600");var created=client.send(HttpRequest.newBuilder(URI.create(endpoint)).timeout(Duration.ofSeconds(60)).header("Tus-Resumable","1.0.0").header("Upload-Length",Long.toString(length)).header("Upload-Metadata",metadata).header("x-signature",signedToken).header("apikey",publishableKey).POST(HttpRequest.BodyPublishers.noBody()).build(),HttpResponse.BodyHandlers.ofString());if(created.statusCode()!=201)throw new IOException("Storage upload creation failed (HTTP "+created.statusCode()+"). "+created.body());uploadUrl=URI.create(endpoint).resolve(created.headers().firstValue("Location").orElseThrow()).toString();state.addProperty(key,uploadUrl);save(ledger,state);offset=0;}
+ if(offset<0||offset>length)throw new IOException("Storage returned an invalid upload offset.");
+ try(RandomAccessFile input=new RandomAccessFile(file.toFile(),"r")){
+ while(offset
///
-import "./.next/dev/types/routes.d.ts";
-import "./.next/dev/types/root-params.d.ts";
+import "./.next/types/routes.d.ts";
+import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/public/fonts/OFL-VT323.txt b/public/fonts/OFL-VT323.txt
new file mode 100644
index 0000000..bcb092f
--- /dev/null
+++ b/public/fonts/OFL-VT323.txt
@@ -0,0 +1,93 @@
+Copyright 2011, The VT323 Project Authors (peter.hull@oikoi.com)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/public/fonts/VT323-Regular.ttf b/public/fonts/VT323-Regular.ttf
new file mode 100644
index 0000000..afa6909
Binary files /dev/null and b/public/fonts/VT323-Regular.ttf differ
diff --git a/public/tools/mod/minegeo-fabric-1.21.11.jar b/public/tools/mod/minegeo-fabric-1.21.11.jar
index 478b625..d6de6d4 100644
Binary files a/public/tools/mod/minegeo-fabric-1.21.11.jar and b/public/tools/mod/minegeo-fabric-1.21.11.jar differ
diff --git a/public/tools/plugin/minegeo-plugin-1.21.11.jar b/public/tools/plugin/minegeo-plugin-1.21.11.jar
index 28ad1b9..3b5ad60 100644
Binary files a/public/tools/plugin/minegeo-plugin-1.21.11.jar and b/public/tools/plugin/minegeo-plugin-1.21.11.jar differ
diff --git a/scripts/java-build.mjs b/scripts/java-build.mjs
index 7775967..2d90083 100644
--- a/scripts/java-build.mjs
+++ b/scripts/java-build.mjs
@@ -4,7 +4,8 @@ import { mkdir, copyFile } from "node:fs/promises";
const windows = process.platform === "win32";
const command = windows ? "gradlew.bat" : "sh";
const targets = process.argv.slice(2);
-if (!targets.length) targets.push(":plugin:build", ":fabric:build");
+if (!targets.some((target) => !target.startsWith("-")))
+ targets.unshift(":plugin:build", ":fabric:build");
await new Promise((resolve, reject) => {
const child = spawn(command, windows ? targets : ["./gradlew", ...targets], {
cwd: "minecraft",
diff --git a/scripts/setup.mjs b/scripts/setup.mjs
index c08e723..bbdc648 100644
--- a/scripts/setup.mjs
+++ b/scripts/setup.mjs
@@ -29,5 +29,5 @@ const configured = Boolean(
console.log(
configured
? "Supabase configuration present. Apply the checked-in migration before starting."
- : "Supabase is not configured. Local guest sandbox is available; hosted uploads/moderation remain disabled.",
+ : "Supabase is not configured. Fill the URL, publishable key and server secret in .env.local before starting MineGeo.",
);
diff --git a/scripts/test-api.ts b/scripts/test-api.ts
new file mode 100644
index 0000000..7300569
--- /dev/null
+++ b/scripts/test-api.ts
@@ -0,0 +1,210 @@
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises";
+import { createClient } from "@supabase/supabase-js";
+process.loadEnvFile(".env.local");
+const db = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.SUPABASE_SECRET_KEY!,
+ { auth: { persistSession: false } },
+);
+const account = JSON.parse(await readFile(".data/test-account.json", "utf8"));
+const origin = "http://127.0.0.1:3100",
+ directory = `.data/api-smoke-${Date.now()}`;
+await mkdir(directory, { recursive: true });
+const manifest = JSON.parse(
+ await readFile(
+ ".cache/paper-proof/plugins/MineGeo/export/manifest.json",
+ "utf8",
+ ),
+);
+manifest.name = "Private API regression";
+manifest.serverName = "MineGeo API regression";
+await writeFile(`${directory}/manifest.json`, JSON.stringify(manifest));
+await copyFile(
+ ".cache/paper-proof/plugins/MineGeo/export/terrain.json.gz",
+ `${directory}/terrain.json.gz`,
+);
+const server = spawn(
+ process.execPath,
+ [
+ "node_modules/next/dist/bin/next",
+ "start",
+ "--hostname",
+ "127.0.0.1",
+ "--port",
+ "3100",
+ ],
+ { stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
+);
+let serverLog = "";
+server.stdout.on("data", (data) => (serverLog += data));
+server.stderr.on("data", (data) => (serverLog += data));
+const stopped = new Promise((resolve) =>
+ server.on("exit", () => resolve()),
+);
+const request = (path: string, body?: unknown, authenticated = true) =>
+ fetch(origin + "/api/v1/" + path, {
+ method: body === undefined ? "GET" : "POST",
+ headers: {
+ ...(authenticated ? { authorization: "Bearer " + account.token } : {}),
+ "content-type": "application/json",
+ },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+let datasetId: string | undefined, serverId: string | undefined;
+try {
+ let ready = false;
+ for (let i = 0; i < 60; i++) {
+ try {
+ const response = await fetch(origin + "/api/v1/health");
+ if (response.ok) {
+ ready = true;
+ break;
+ }
+ } catch {}
+ await new Promise((r) => setTimeout(r, 1000));
+ }
+ assert.ok(ready, "Production server and database must become healthy");
+ for (const path of [
+ "/",
+ "/servers",
+ "/downloads",
+ "/fonts/VT323-Regular.ttf",
+ "/images/example-map.webp",
+ "/tools/plugin/minegeo-plugin-1.21.11.jar",
+ "/tools/mod/minegeo-fabric-1.21.11.jar",
+ ]) {
+ const response = await fetch(origin + path);
+ assert.equal(response.status, 200, path);
+ const bytes = await response.arrayBuffer();
+ assert.ok(bytes.byteLength > 0);
+ if (path === "/") {
+ const html = new TextDecoder().decode(bytes);
+ assert.ok(html.includes("blocky world"));
+ assert.ok(html.includes("COMMUNITY MAPS"));
+ }
+ console.log("HTTP 200 " + path);
+ }
+ const child = spawn(
+ "java",
+ [
+ "--class-path",
+ "public/tools/plugin/minegeo-plugin-1.21.11.jar",
+ "tests/java/UploadSmoke.java",
+ origin,
+ directory,
+ ".data/test-account.json",
+ ],
+ { stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
+ );
+ let javaLog = "";
+ child.stdout.on("data", (data) => {
+ javaLog += data;
+ process.stdout.write(data);
+ });
+ child.stderr.on("data", (data) => (javaLog += data));
+ const code = await new Promise((resolve) =>
+ child.on("exit", resolve),
+ );
+ assert.equal(code, 0, javaLog);
+ const ledger = JSON.parse(
+ await readFile(`${directory}/.upload-state.json`, "utf8"),
+ );
+ datasetId = ledger.datasetId;
+ const draftResponse = await request("datasets/" + datasetId);
+ assert.equal(draftResponse.status, 200);
+ const draft = await draftResponse.json();
+ serverId = draft.server_id;
+ assert.ok(["processing", "draft"].includes(draft.state));
+ if (draft.state === "processing")
+ assert.ok(
+ draft.previews.every((p: any) => p.image === null),
+ "Pending media must not be signed",
+ );
+ const anonymous = await request("datasets/" + datasetId, undefined, false);
+ assert.ok(anonymous.status >= 400, "Private draft must reject guests");
+ if (draft.state === "processing") {
+ const cancel = await request("datasets/" + datasetId + "/cancel", {});
+ assert.equal(cancel.status, 200, await cancel.text());
+ }
+ // A never-finalized upload has no queue row: cancellation must still work.
+ const created = await request("uploads", { manifest });
+ assert.equal(created.status, 200);
+ const empty = await created.json();
+ const cancelled = await request(
+ "datasets/" + empty.datasetId + "/cancel",
+ {},
+ );
+ assert.equal(cancelled.status, 200, await cancelled.text());
+ const cleanup = await db
+ .from("datasets")
+ .delete()
+ .eq("id", empty.datasetId)
+ .eq("owner_id", account.id);
+ if (cleanup.error) throw cleanup.error;
+ await writeFile(
+ "artifacts-api-smoke.json",
+ JSON.stringify(
+ {
+ passed: true,
+ checks: [
+ "production homepage/catalog/downloads/assets",
+ "Java uploader to real Supabase Storage",
+ "private draft before rendered assets exist",
+ "guests denied private drafts",
+ "cancel queued processing",
+ "cancel unfinished upload without a queue row",
+ ],
+ },
+ null,
+ 2,
+ ),
+ );
+ console.log("REAL STORAGE / API SMOKE PASSED");
+} finally {
+ if (!datasetId) {
+ try {
+ datasetId = JSON.parse(
+ await readFile(`${directory}/.upload-state.json`, "utf8"),
+ ).datasetId;
+ } catch {}
+ }
+ if (datasetId) {
+ const record = await db
+ .from("datasets")
+ .select("server_id")
+ .eq("id", datasetId)
+ .eq("owner_id", account.id)
+ .maybeSingle();
+ serverId = record.data?.server_id ?? serverId;
+ await db.storage
+ .from("minegeo-raw")
+ .remove(
+ manifest.assets.map((a: any) => `${account.id}/${datasetId}/${a.path}`),
+ );
+ const deleted = await db
+ .from("datasets")
+ .delete()
+ .eq("id", datasetId)
+ .eq("owner_id", account.id);
+ if (deleted.error)
+ console.error("Test dataset cleanup: " + deleted.error.message);
+ }
+ if (serverId) {
+ const remaining = await db
+ .from("datasets")
+ .select("id")
+ .eq("server_id", serverId)
+ .limit(1);
+ if (remaining.data?.length === 0)
+ await db
+ .from("catalog_servers")
+ .delete()
+ .eq("id", serverId)
+ .eq("name", "MineGeo API regression");
+ }
+ server.kill();
+ await stopped;
+ await writeFile(`${directory}/server.log`, serverLog);
+}
diff --git a/scripts/test-plugin.ts b/scripts/test-plugin.ts
new file mode 100644
index 0000000..80f606c
--- /dev/null
+++ b/scripts/test-plugin.ts
@@ -0,0 +1,232 @@
+import { createServer } from "node:http";
+import { spawn } from "node:child_process";
+import { mkdir, copyFile, writeFile, readFile } from "node:fs/promises";
+import { createHash } from "node:crypto";
+import { gunzipSync } from "node:zlib";
+import assert from "node:assert/strict";
+import { datasetSchema } from "../shared/protocol";
+
+// Disposable, loopback-only Paper test. No game client is launched.
+const directory = `.cache/plugin-regression-${Date.now()}`;
+await mkdir(`${directory}/plugins/MineGeo`, { recursive: true });
+await copyFile(".cache/paper-proof/paper.jar", `${directory}/paper.jar`);
+await copyFile(
+ "public/tools/plugin/minegeo-plugin-1.21.11.jar",
+ `${directory}/plugins/minegeo.jar`,
+);
+await writeFile(`${directory}/eula.txt`, "eula=true\n");
+await writeFile(
+ `${directory}/server.properties`,
+ 'server-ip=127.0.0.1\nserver-port=25580\nonline-mode=true\nlevel-type=minecraft:flat\ngenerator-settings={"layers":[{"block":"minecraft:bedrock","height":1},{"block":"minecraft:dirt","height":2},{"block":"minecraft:grass_block","height":1}],"biome":"minecraft:plains"}\ngenerate-structures=false\nview-distance=2\nsimulation-distance=2\nspawn-protection=0\nmax-players=1\n',
+);
+let manifest: any,
+ received = Buffer.alloc(0),
+ stage = "uploading",
+ interrupted = false,
+ creates = 0,
+ finalizes = 0,
+ heads = 0;
+const api = createServer(async (req, res) => {
+ const chunks: Buffer[] = [];
+ for await (const chunk of req) chunks.push(chunk);
+ const bytes = Buffer.concat(chunks);
+ const json = (data: unknown, status = 200) => {
+ res.writeHead(status, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(data));
+ };
+ const origin = `http://127.0.0.1:${(api.address() as any).port}`;
+ if (req.url === "/api/v1/uploads") {
+ creates++;
+ manifest = JSON.parse(bytes.toString()).manifest;
+ json({ datasetId: "fixture" });
+ } else if (req.url === "/api/v1/datasets/fixture") json({ state: stage });
+ else if (req.url === "/api/v1/uploads/sign")
+ json({
+ assets: manifest.assets.map((a: any) => ({
+ ...a,
+ endpoint: origin + "/tus",
+ token: "fixture-signature",
+ objectName: a.path,
+ })),
+ });
+ else if (req.url === "/tus") {
+ res.writeHead(201, { Location: origin + "/tus/file" });
+ res.end();
+ } else if (req.url === "/tus/file" && req.method === "HEAD") {
+ heads++;
+ res.writeHead(200, { "Upload-Offset": String(received.length) });
+ res.end();
+ } else if (req.url === "/tus/file" && req.method === "PATCH") {
+ assert.equal(Number(req.headers["upload-offset"]), received.length);
+ if (!interrupted) {
+ interrupted = true;
+ received = Buffer.concat([
+ received,
+ bytes.subarray(0, Math.floor(bytes.length / 2)),
+ ]);
+ res.writeHead(503);
+ res.end("Simulated interruption");
+ } else {
+ received = Buffer.concat([received, bytes]);
+ res.writeHead(204, { "Upload-Offset": String(received.length) });
+ res.end();
+ }
+ } else if (req.url === "/api/v1/uploads/finalize") {
+ finalizes++;
+ assert.equal(
+ createHash("sha256").update(received).digest("hex"),
+ manifest.assets[0].sha256,
+ );
+ stage = "processing";
+ json({ state: stage });
+ } else
+ json({ error: "Unexpected request " + req.method + " " + req.url }, 404);
+});
+await new Promise((r) => api.listen(0, "127.0.0.1", r));
+await writeFile(
+ `${directory}/plugins/MineGeo/config.yml`,
+ `website: 'http://127.0.0.1:${(api.address() as any).port}'\ntoken: 'fixture-token'\nserver-name: 'Regression world'\nchunks-per-tick: 2\nviewpoints: 5\nmax-area-size: 2048\n`,
+);
+const child = spawn("java", ["-Xmx1536M", "-jar", "paper.jar", "--nogui"], {
+ cwd: directory,
+ stdio: ["pipe", "pipe", "pipe"],
+ windowsHide: true,
+});
+let output = "",
+ waiters: {
+ pattern: RegExp;
+ resolve: () => void;
+ reject: (e: Error) => void;
+ start: number;
+ timer: ReturnType;
+ }[] = [];
+const log = (bytes: Buffer) => {
+ const text = bytes.toString();
+ output += text;
+ for (const line of text.split("\n"))
+ if (/MineGeo|Done \(|ERROR/.test(line)) process.stdout.write(line + "\n");
+ for (const waiter of [...waiters])
+ if (waiter.pattern.test(output.slice(waiter.start))) {
+ clearTimeout(waiter.timer);
+ waiters = waiters.filter((w) => w !== waiter);
+ waiter.resolve();
+ }
+};
+child.stdout.on("data", log);
+child.stderr.on("data", log);
+const exited = new Promise((resolve) =>
+ child.on("exit", () => {
+ for (const w of waiters) {
+ clearTimeout(w.timer);
+ w.reject(new Error("Paper stopped before " + w.pattern));
+ }
+ waiters = [];
+ resolve();
+ }),
+);
+const wait = (pattern: RegExp, start = output.length, timeout = 90000) =>
+ new Promise((resolve, reject) => {
+ const w = {
+ pattern,
+ start,
+ resolve,
+ reject,
+ timer: setTimeout(() => {
+ waiters = waiters.filter((v) => v !== w);
+ reject(new Error("Timed out waiting for " + pattern));
+ }, timeout),
+ };
+ waiters.push(w);
+ if (pattern.test(output.slice(start))) {
+ clearTimeout(w.timer);
+ waiters = waiters.filter((v) => v !== w);
+ resolve();
+ }
+ });
+const command = async (text: string, pattern: RegExp) => {
+ const pending = wait(pattern);
+ child.stdin.write(text + "\n");
+ await pending;
+};
+try {
+ await wait(/Done \(/, 0, 180000);
+ await command("minegeo upload", /No completed export/);
+ await command(
+ "minegeo area world 100000 100000 100032 100032",
+ /area selected/,
+ );
+ await command(
+ "minegeo export Empty Region",
+ /Export failed: No generated chunks/,
+ );
+ await command("minegeo area world -32 -32 32 32", /area selected/);
+ child.stdin.write("forceload add -32 -32 31 31\n");
+ await new Promise((r) => setTimeout(r, 5000));
+ await command("minegeo export Regression World", /Export complete:/);
+ const relative = (
+ await readFile(`${directory}/plugins/MineGeo/latest-export.txt`, "utf8")
+ ).trim();
+ const exportDir = `${directory}/plugins/MineGeo/${relative}`;
+ const local = datasetSchema.parse(
+ JSON.parse(await readFile(`${exportDir}/manifest.json`, "utf8")),
+ );
+ assert.equal(local.viewpoints.length, 5);
+ const terrain = JSON.parse(
+ gunzipSync(await readFile(`${exportDir}/terrain.json.gz`)).toString(),
+ );
+ assert.ok(terrain.blocks.length > 0);
+ assert.ok(terrain.coverage.length > 0);
+ assert.ok(
+ local.viewpoints.every(
+ (p) => p.x >= -32 && p.x < 32 && p.z >= -32 && p.z < 32,
+ ),
+ );
+ await command("minegeo upload", /Upload failed: Upload paused/);
+ await command("minegeo upload", /Upload complete\. Open/);
+ assert.equal(creates, 1);
+ assert.equal(finalizes, 1);
+ assert.equal(heads, 1);
+ await command("minegeo upload", /Upload complete\. Open/);
+ assert.equal(finalizes, 1);
+ await command("minegeo area world -256 -256 256 256", /area selected/);
+ const cancelled = wait(/Cancelled\. Completed exports are kept/);
+ child.stdin.write("minegeo export Cancel Test\nminegeo cancel\n");
+ await cancelled;
+ assert.equal(
+ (
+ await readFile(`${directory}/plugins/MineGeo/latest-export.txt`, "utf8")
+ ).trim(),
+ relative,
+ );
+ await writeFile(
+ "artifacts-plugin-regression.json",
+ JSON.stringify(
+ {
+ passed: true,
+ checks: [
+ "missing export guidance",
+ "empty area failure",
+ "real Paper export without a player",
+ "shared schema validation",
+ "TUS interruption and byte-perfect resume",
+ "repeated upload is idempotent",
+ "cancellation preserves completed export",
+ ],
+ blocks: terrain.blocks.length,
+ chunks: terrain.coverage.length,
+ viewpoints: local.viewpoints.length,
+ directory,
+ },
+ null,
+ 2,
+ ),
+ );
+ console.log("PLUGIN REGRESSION PASSED");
+} finally {
+ child.stdin.write("stop\n");
+ const kill = setTimeout(() => child.kill(), 15000);
+ await exited;
+ clearTimeout(kill);
+ await new Promise((r) => api.close(() => r()));
+ await writeFile(`${directory}/regression.log`, output);
+}
diff --git a/shared/protocol.ts b/shared/protocol.ts
index 27ebbec..a3f477d 100644
--- a/shared/protocol.ts
+++ b/shared/protocol.ts
@@ -56,6 +56,23 @@ export const datasetSchema = z
.max(4096),
})
.superRefine((d, ctx) => {
+ const paths = new Set(d.assets.map((asset) => asset.path));
+ const requiredPaths =
+ d.source === "plugin"
+ ? ["terrain.json.gz"]
+ : [
+ "map.png",
+ ...d.viewpoints.flatMap((point) =>
+ faces.map((face) => `${point.id}/${face}.png`),
+ ),
+ ];
+ for (const path of requiredPaths) {
+ if (!paths.has(path))
+ ctx.addIssue({
+ code: "custom",
+ message: `Missing required export asset: ${path}`,
+ });
+ }
if (new Set(d.viewpoints.map((p) => p.id)).size !== d.viewpoints.length)
ctx.addIssue({ code: "custom", message: "Viewpoint IDs must be unique" });
if (new Set(d.assets.map((a) => a.path)).size !== d.assets.length)
diff --git a/tests/java/UploadSmoke.java b/tests/java/UploadSmoke.java
new file mode 100644
index 0000000..f4494f2
--- /dev/null
+++ b/tests/java/UploadSmoke.java
@@ -0,0 +1,9 @@
+import dev.minegeo.plugin.UploadClient;
+import com.google.gson.JsonParser;
+import java.nio.file.*;
+public class UploadSmoke {
+ public static void main(String[] args) throws Exception {
+ String token=JsonParser.parseString(Files.readString(Path.of(args[2]))).getAsJsonObject().get("token").getAsString();
+ new UploadClient(args[0],token).upload(Path.of(args[1]),message->System.out.println(message),()->false);
+ }
+}
diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts
index 5a80113..c0eeacd 100644
--- a/tests/protocol.test.ts
+++ b/tests/protocol.test.ts
@@ -46,6 +46,29 @@ test("dataset requires five unique in-bounds viewpoints and safe paths", () => {
assets: [{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 100 }],
};
assert.equal(datasetSchema.safeParse(base).success, true);
+ assert.equal(datasetSchema.safeParse({ ...base, assets: [] }).success, false);
+ assert.equal(
+ datasetSchema.safeParse({ ...base, source: "fabric" }).success,
+ false,
+ );
+ const capture = {
+ ...base,
+ source: "fabric",
+ assets: [
+ "map.png",
+ ...base.viewpoints.flatMap((point) =>
+ ["px", "nx", "py", "ny", "pz", "nz"].map(
+ (face) => `${point.id}/${face}.png`,
+ ),
+ ),
+ ].map((path) => ({ path, sha256: "a".repeat(64), bytes: 100 })),
+ };
+ assert.equal(datasetSchema.safeParse(capture).success, true);
+ assert.equal(
+ datasetSchema.safeParse({ ...capture, assets: capture.assets.slice(1) })
+ .success,
+ false,
+ );
assert.equal(
datasetSchema.safeParse({ ...base, viewpoints: base.viewpoints.slice(1) })
.success,
diff --git a/worker/main.ts b/worker/main.ts
index 354ac8a..1bf0af9 100644
--- a/worker/main.ts
+++ b/worker/main.ts
@@ -1,4 +1,5 @@
import { createClient } from "@supabase/supabase-js";
+import { existsSync } from "node:fs";
import { randomUUID, createHash } from "node:crypto";
import { mkdir, writeFile, readFile, readdir, rm } from "node:fs/promises";
import { gunzipSync } from "node:zlib";
@@ -6,6 +7,7 @@ import sharp from "sharp";
import { datasetSchema, faces, type TerrainSnapshot } from "../shared/protocol";
import { renderSnapshot } from "../renderer/render.mjs";
import type { Database } from "../shared/database";
+if (existsSync(".env.local")) process.loadEnvFile(".env.local");
const client = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!,