Refresh the homepage and improve export and draft guidance

This commit is contained in:
2026-10-04 17:48:39 +05:00
parent 04cc4f39f6
commit 244d27a309
33 changed files with 2110 additions and 215 deletions

View File

@@ -12,7 +12,5 @@ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
SUPABASE_SECRET_KEY= SUPABASE_SECRET_KEY=
# Optional direct PostgreSQL connection for maintenance. Runtime uses the Supabase API. # Optional direct PostgreSQL connection for maintenance. Runtime uses the Supabase API.
DATABASE_URL= DATABASE_URL=
# Explicit local guest sandbox when no hosted project is configured.
MINEGEO_LOCAL_MODE=true
MINEGEO_FACE_SIZE=1024 MINEGEO_FACE_SIZE=1024
MINEGEO_MAX_UPLOAD_BYTES=268435456 MINEGEO_MAX_UPLOAD_BYTES=268435456

57
README.md Normal file
View File

@@ -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`.

918
app/block-theme.css Normal file
View File

@@ -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;
}
}

View File

@@ -77,13 +77,20 @@ export default function Downloads() {
<p className="muted">The plugin build is not available yet.</p> <p className="muted">The plugin build is not available yet.</p>
)} )}
<pre> <pre>
/minegeo area world -256 -256 256 256{"\n"}/minegeo export My {
Overworld{"\n"}/minegeo link &lt;dashboard token&gt;{"\n"}/minegeo "/minegeo website https://your-minegeo-site.example\n/minegeo link <dashboard token>\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"
upload }
</pre> </pre>
<p className="muted">
Start with a small, explored area around your builds. Exports are
saved in <code>plugins/MineGeo/exports/export-…/</code>. The
manifest appears when export finishes. Upload sends the latest
completed export; it does not capture the world.
</p>
<p className="muted" style={{ fontSize: 11 }}> <p className="muted" style={{ fontSize: 11 }}>
Put the JAR in plugins/ and restart. Set website and server-name 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.
</p> </p>
</article> </article>
</div> </div>

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import "./block-theme.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: "MineGeo — Get lost in a familiar world", default: "MineGeo — Get lost in a familiar world",

View File

@@ -2,190 +2,246 @@ import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { import {
ArrowRight, ArrowRight,
ArrowUpRight,
Compass, Compass,
Cuboid,
Map,
MapPin, MapPin,
Move,
Timer, Timer,
Blocks,
Upload,
} from "lucide-react"; } from "lucide-react";
import { Header, Footer, EmptyState } from "@/components/Shell"; import { Header, Footer } from "@/components/Shell";
import { MapCard } from "@/components/MapCard"; import { MapCard } from "@/components/MapCard";
import { catalog } from "@/lib/catalog"; import { catalog } from "@/lib/catalog";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function Home() { export default async function Home() {
const maps = await catalog(); const maps = await catalog();
const serverCount = new Set(maps.map((m) => m.server_id)).size; const servers = new Set(maps.map((map) => map.server_id)).size;
const locations = maps.reduce((n, m) => n + m.panorama_count, 0); const locations = maps.reduce((total, map) => total + map.panorama_count, 0);
return ( return (
<> <>
<Header /> <Header />
<main> <main className="home-page">
<section className="hero"> <section className="hero">
<div className="hero-grid" />
<div className="hero-copy"> <div className="hero-copy">
<div className="status-label"> <div className="status-label">
<span className="live-dot" /> MINECRAFT WORLDS. REAL EXPLORATION. <span className="live-dot" /> MINECRAFT · EXPLORE · GUESS
</div> </div>
<h1> <h1>
Get lost in a<br /> Guess where you are
<span>familiar world.</span> <br />
in a <span>blocky world.</span>
</h1> </h1>
<p> <p>
Somewhere between the blocks, there’s a place you know. Drop into a random corner of a Minecraft world.
<br className="desktop" /> Look around. Trust your instincts. Make <br className="desktop" /> Look around, spot the landmarks, and
your guess. place your pin
<br className="desktop" /> before the clock runs out.
</p> </p>
<div className="hero-actions"> <div className="hero-actions">
<Link href="/servers" className="button primary"> <Link href="/servers" className="button primary">
Find your next world <ArrowRight size={18} /> Start exploring <ArrowRight size={18} />
</Link>
<Link href="/about" className="text-link">
How it works <ArrowUpRight size={15} />
</Link> </Link>
<span className="hero-mode-note">3 difficulty levels</span>
</div> </div>
<div className="hero-notes"> <div className="hero-notes">
<span> <span className="mode-dots" aria-hidden="true">
<Compass size={15} /> 360° panoramas <i />
</span> <i />
<span> <i />
<Move size={15} /> No roads. No moving. <i />
</span>
<span>
<Cuboid size={15} /> Just you and the blocks.
</span> </span>
<span>Five rounds. No roads. Just your instincts.</span>
</div> </div>
</div> </div>
<div <div className="hero-art" aria-label="Minecraft surface map">
className="hero-art"
aria-label="Minecraft terrain map illustration"
>
<div className="artifact-caption">
YOUR NEXT ADVENTURE IS OUT THERE
</div>
<div className="floating-coord">
<span className="square-dot" /> OVERWORLD
<span className="coord-muted">X ? · Z ?</span>
</div>
<div className="map-art-frame"> <div className="map-art-frame">
<Image <Image
src="/images/example-map.webp" src={maps[0]?.mapImage || "/images/example-map.webp"}
alt="Top-down map of the real Paper renderer test world" alt="A Minecraft surface map framed in parchment"
width={1024} width={1024}
height={1024} height={1024}
priority priority
unoptimized unoptimized
/> />
<div className="map-grid" /> <span className="square-pin pin-green" />
<div className="map-pin"> <span className="square-pin pin-red" />
<MapPin size={38} fill="currentColor" /> <span className="square-pin pin-gold" />
</div> </div>
<div className="map-corner top-left" /> <span className="compass-art" aria-hidden="true">
<div className="map-corner bottom-right" /> <Compass size={43} />
</div>
<span className="compass-art">
<Compass size={42} />
<small>N</small> <small>N</small>
</span> </span>
<div className="floating-tag"> <div className="floating-tag">
<MapPin size={15} /> ONE WORLD. COUNTLESS POSSIBILITIES. <MapPin size={14} />
<strong>X: ??? Z: ???</strong>
<span>Overworld</span>
</div> </div>
</div> </div>
</section> </section>
<div className="grass-divider"> <div className="grass-divider" aria-hidden="true">
<span /> <span />
</div> </div>
<section className="world-stats"> <section className="world-stats" aria-label="Community statistics">
<div> <div>
<strong>{serverCount}</strong> <i className="stat-cube green-cube" />
<div>
<strong>{servers.toLocaleString()}</strong>
<span>COMMUNITY SERVERS</span> <span>COMMUNITY SERVERS</span>
</div> </div>
<div>
<strong>{maps.length}</strong>
<span>WORLDS TO EXPLORE</span>
</div> </div>
<div> <div>
<strong>{locations}</strong> <i className="stat-cube gold-cube" />
<span>PLACES TO GET LOST</span> <div>
<strong>{maps.length.toLocaleString()}</strong>
<span>PUBLISHED MAPS</span>
</div>
</div>
<div>
<i className="stat-cube cyan-cube" />
<div>
<strong>{locations.toLocaleString()}</strong>
<span>PLACES TO DISCOVER</span>
</div>
</div>
<div>
<i className="stat-cube red-cube" />
<div>
<strong>5 rounds</strong>
<span>EVERY EXPEDITION</span>
</div> </div>
<div className="stat-note">
<span className="live-dot" />
Built by explorers.
<br />
For explorers.
</div> </div>
</section> </section>
<section className="section catalog-section"> <section className="section catalog-section">
<div className="section-heading"> <div className="section-heading">
<div> <div>
<span className="eyebrow">PICK A WORLD. DROP A PIN.</span> <span className="eyebrow">COMMUNITY MAPS</span>
<h2>Where to today?</h2> <h2>Where will you go today?</h2>
</div> </div>
<Link className="text-link" href="/servers"> <Link className="text-link" href="/servers">
All worlds <ArrowRight size={16} /> All {maps.length} maps <ArrowRight size={15} />
</Link> </Link>
</div> </div>
{maps.length ? ( {maps.length ? (
<div className="cards-grid"> <div className="cards-grid">
{maps.slice(0, 3).map((m, i) => ( {maps.slice(0, 4).map((map, index) => (
<MapCard map={m} index={i} key={m.id} /> <MapCard map={map} index={index} key={map.id} />
))} ))}
</div> </div>
) : ( ) : (
<EmptyState title="The world is yours to map"> <div className="first-map-panel">
The catalog is waiting for its first approved community map. <div className="empty-map-art" aria-hidden="true">
Capture a world with our mod or server plugin. <Image
</EmptyState> src="/images/example-map.webp"
)} alt=""
</section> width={180}
<section className="section journey"> height={180}
<div className="journey-intro"> unoptimized
<span className="eyebrow">A NEW WAY TO SEE MINECRAFT</span> />
<h2> <span>YOUR WORLD HERE</span>
Every block </div>
<br /> <div>
tells a story. <span className="eyebrow">
</h2> THE FIRST EXPEDITION STARTS WITH YOU
</span>
<h3>Put your server on the map.</h3>
<p> <p>
That mountain. That oddly placed tower. That very suspicious dirt No community maps have been published yet. Export your world
hut. You’ve been here before… right? with the server plugin, or capture five locations with the
Fabric mod. Preview your map and submit it for review.
</p> </p>
<Link href="/about" className="text-link"> <Link className="button primary" href="/downloads">
Learn how to play <ArrowRight size={16} /> <Upload size={16} /> Get the capture tools
</Link> </Link>
</div> </div>
<div className="steps">
<article>
<span>01</span>
<Compass />
<h3>Take a look around</h3>
<p>Find clues in a full 360° view of a Minecraft world.</p>
</article>
<article>
<span>02</span>
<Map />
<h3>Trust your coordinates</h3>
<p>Open the map and pin the spot where you think you are.</p>
</article>
<article>
<span>03</span>
<Timer />
<h3>Beat the clock</h3>
<p>Five rounds. One world. How close can you get?</p>
</article>
</div> </div>
)}
</section> </section>
<section className="section contribute-banner"> <section className="section home-panels">
<article className="home-panel">
<span className="eyebrow">HOW TO PLAY</span>
<h2>Know your blocks?</h2>
<ol className="play-steps">
<li>
<span>01</span>
<div> <div>
<span className="eyebrow">YOUR SERVER DESERVES TO BE EXPLORED</span> <h3>Pick your world</h3>
<h2>Put your world on the map.</h2> <p>Choose a community server, map, and difficulty.</p>
</div>
</li>
<li>
<span>02</span>
<div>
<h3>Look for the clues</h3>
<p> <p>
Bring your builds, your landscapes, and your community to MineGeo. Rotate the panorama. Find builds, biomes, and landmarks.
</p> </p>
</div> </div>
<Link href="/downloads" className="button primary"> </li>
Create a map <ArrowUpRight size={18} /> <li>
<span>03</span>
<div>
<h3>Make your guess</h3>
<p>Drop a pin on the map. Closer guesses earn more points.</p>
</div>
</li>
</ol>
<Link className="text-link" href="/about">
Read the field guide <ArrowRight size={14} />
</Link>
</article>
<article className="home-panel">
<span className="eyebrow">CHOOSE YOUR CHALLENGE</span>
<h2>One world. Three ways.</h2>
<div className="mode-list">
{[
{
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) => (
<Link href="/servers" className="mode-row" key={mode.name}>
<i className={`stat-cube ${mode.color}-cube`} />
<div>
<h3>
{mode.name}
<span>
<Timer size={12} />
{mode.time}
</span>
</h3>
<p>{mode.detail}</p>
</div>
<ArrowRight size={15} />
</Link>
))}
</div>
<p className="mode-footnote">
Up to 5,000 points per round. How close can you get?
</p>
</article>
</section>
<section className="section contribute-banner">
<Blocks size={38} />
<div>
<span className="eyebrow">BUILT BY THE COMMUNITY</span>
<h2>Your world deserves to be explored.</h2>
<p>Share your builds and your favorite corners of Minecraft.</p>
</div>
<Link href="/downloads" className="button">
Create a map <ArrowRight size={16} />
</Link> </Link>
</section> </section>
</main> </main>

11
artifacts-api-smoke.json Normal file
View File

@@ -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"
]
}

View File

@@ -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"
}

View File

@@ -9,6 +9,7 @@ type Draft = {
description: string; description: string;
state: string; state: string;
source: string; source: string;
rejection_reason: string | null;
map: string | null; map: string | null;
manifest: DatasetManifest; manifest: DatasetManifest;
previews: { id: string; image: string | null }[]; previews: { id: string; image: string | null }[];
@@ -22,14 +23,27 @@ export function DraftEditor({ id }: { id: string }) {
[message, setMessage] = useState(""), [message, setMessage] = useState(""),
[busy, setBusy] = useState(false); [busy, setBusy] = useState(false);
useEffect(() => { useEffect(() => {
let disposed = false;
let timer: ReturnType<typeof setTimeout>;
const load = () =>
api<Draft>("datasets/" + id) api<Draft>("datasets/" + id)
.then((d) => { .then((d) => {
if (disposed) return;
setDraft(d); setDraft(d);
setName(d.name); setName(d.name);
setDescription(d.description); setDescription(d.description);
setSelected(d.manifest.viewpoints.map((p) => p.id)); setSelected(d.manifest.viewpoints.map((p) => p.id));
if (["uploading", "processing"].includes(d.state))
timer = setTimeout(load, 5000);
}) })
.catch((e) => setError(e.message)); .catch((e) => {
if (!disposed) setError(e.message);
});
load();
return () => {
disposed = true;
clearTimeout(timer);
};
}, [id]); }, [id]);
async function save(submit = false) { async function save(submit = false) {
setBusy(true); setBusy(true);
@@ -55,6 +69,11 @@ export function DraftEditor({ id }: { id: string }) {
setBusy(true); setBusy(true);
try { try {
await api("datasets/" + id + "/cancel", {}); await api("datasets/" + id + "/cancel", {});
setDraft((d) =>
d
? { ...d, state: "failed", rejection_reason: "Processing cancelled" }
: d,
);
setMessage("Processing cancelled."); setMessage("Processing cancelled.");
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
@@ -70,6 +89,18 @@ export function DraftEditor({ id }: { id: string }) {
return ( return (
<> <>
<span className="badge">{draft.state}</span> <span className="badge">{draft.state}</span>
{["uploading", "processing"].includes(draft.state) && (
<p role="status" className="success-message">
{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."}
</p>
)}
{draft.rejection_reason && (
<p className="error-message" role="alert">
{draft.rejection_reason}
</p>
)}
<p className="muted"> <p className="muted">
{draft.source === "plugin" {draft.source === "plugin"
? "Server-generated views use vanilla terrain and simplified lighting. Entities, shaders, custom packs and dynamic sign text are omitted." ? "Server-generated views use vanilla terrain and simplified lighting. Entities, shaders, custom packs and dynamic sign text are omitted."

View File

@@ -17,12 +17,12 @@ export function Header() {
<div className="header-inner"> <div className="header-inner">
<Brand /> <Brand />
<nav aria-label="Main navigation"> <nav aria-label="Main navigation">
<Link href="/servers">Explore servers</Link> <Link href="/servers">Play & maps</Link>
<Link href="/downloads">Create a map</Link> <Link href="/downloads">Downloads</Link>
<Link href="/about">How to play</Link> <Link href="/about">Guide</Link>
</nav> </nav>
<Link href="/dashboard" className="header-account"> <Link href="/dashboard" className="header-account">
My expedition <ArrowUpRight size={15} /> My account <ArrowUpRight size={15} />
</Link> </Link>
</div> </div>
</header> </header>

61
docs/server-plugin.md Normal file
View File

@@ -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 <token copied from the dashboard>
```
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.

22
docs/verification.md Normal file
View File

@@ -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`.

View File

@@ -294,7 +294,7 @@ export async function handle(request: NextRequest, path: string[]) {
.single(), .single(),
); );
const endpoint = new URL( const endpoint = new URL(
"/storage/v1/upload/resumable", "/storage/v1/upload/resumable/sign",
process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_URL,
); );
endpoint.hostname = endpoint.hostname.replace( endpoint.hostname = endpoint.hostname.replace(
@@ -315,6 +315,7 @@ export async function handle(request: NextRequest, path: string[]) {
path: a.path, path: a.path,
objectName, objectName,
token: signed.token, token: signed.token,
apiKey: process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
endpoint: endpoint.toString(), endpoint: endpoint.toString(),
}; };
}), }),
@@ -368,7 +369,9 @@ export async function handle(request: NextRequest, path: string[]) {
previews: await Promise.all( previews: await Promise.all(
manifest.viewpoints.map(async (p: { id: string }) => ({ manifest.viewpoints.map(async (p: { id: string }) => ({
id: p.id, 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 }); return NextResponse.json({ ok: true });
} }
if (method === "POST" && path[2] === "cancel") { if (method === "POST" && path[2] === "cancel") {
required( if (!["uploading", "processing"].includes(d.state))
await client throw new Error("This dataset is not uploading or processing");
const cancelled = await client
.from("upload_jobs") .from("upload_jobs")
.update({ state: "cancelled" }) .update({ state: "cancelled" })
.eq("dataset_id", d.id) .eq("dataset_id", d.id)
.in("state", ["queued", "running"]) .in("state", ["queued", "running"])
.select("id") .select("id")
.single(), .maybeSingle();
); if (cancelled.error) throw new Error(cancelled.error.message);
required(
await client await client
.from("datasets") .from("datasets")
.update({ state: "failed", rejection_reason: "Processing cancelled" }) .update({
state: "failed",
rejection_reason: "Processing cancelled",
})
.eq("id", d.id) .eq("id", d.id)
.in("state", ["uploading", "processing"]); .in("state", ["uploading", "processing"])
.select("id")
.single(),
);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
} }

View File

@@ -40,7 +40,8 @@ export async function catalog() {
return Promise.all( return Promise.all(
maps.map(async (m) => ({ maps.map(async (m) => ({
...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), server: servers.find((s) => s.id === m.server_id),
})), })),
); );

View File

@@ -1 +1 @@
allprojects { group = 'dev.minegeo'; version = '0.1.0'; repositories { mavenCentral() } } allprojects { group = 'dev.minegeo'; version = '0.1.1'; repositories { mavenCentral() } }

View File

@@ -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("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()); 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;} @Override public boolean shouldPause(){return false;}
} }

View File

@@ -17,7 +17,7 @@ public final class MineGeoClient implements ClientModInitializer {
public static MineGeoClient INSTANCE; 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 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 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; public volatile boolean uploading=false;public SurfaceDataset dataset;private SurfaceDataset.Point pending;
private Path config; 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");} @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 savedCapture(Path path){try{if(pending==null||!path.getFileName().toString().equals(pending.id()))return;dataset.points.add(pending);dataset.save();message("Panorama saved locally ("+dataset.points.size()+" total). Press G for preview or upload.");}catch(Exception e){message("Saving capture failed: "+e.getMessage());}finally{pending=null;}}
public void newMap(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");dataset=SurfaceDataset.create(server());message("New 512 × 512 map started. Older maps remain in .minecraft/minegeo.");}catch(Exception e){message(e.getMessage());}} public void newMap(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");dataset=SurfaceDataset.create(server());message("New 512 × 512 map started. Older maps remain in .minecraft/minegeo.");}catch(Exception e){message(e.getMessage());}}
public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient(website,"");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();MinecraftClient.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getOperatingSystem().open(link);});for(int i=0;i<120;i++){Thread.sleep(5000);var response=uploader.pollPairing(code);if(response.get("status").getAsString().equals("approved")){token=response.get("token").getAsString();saveConfig();MinecraftClient.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});} public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient(website,"");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();MinecraftClient.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getOperatingSystem().open(link);});for(int i=0;i<120;i++){Thread.sleep(5000);var response=uploader.pollPairing(code);if(response.get("status").getAsString().equals("approved")){token=response.get("token").getAsString();saveConfig();MinecraftClient.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});}
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;}});}
} }

View File

@@ -1,73 +1,144 @@
package dev.minegeo.plugin; package dev.minegeo.plugin;
import com.google.gson.*; import com.google.gson.*;
import com.google.gson.stream.JsonWriter;
import org.bukkit.*; import org.bukkit.*;
import org.bukkit.command.*; import org.bukkit.command.*;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.scheduler.BukkitRunnable;
import java.io.*; import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*; import java.nio.file.*;
import java.security.MessageDigest;
import java.util.*; 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; import java.util.zip.GZIPOutputStream;
public final class MineGeoPlugin extends JavaPlugin { public final class MineGeoPlugin extends JavaPlugin {
private World selectedWorld; private World selectedWorld;
private int minX=-256,minZ=-256,maxX=256,maxZ=256; private int minX=-256,minZ=-256,maxX=256,maxZ=256;
private final AtomicBoolean cancelled=new AtomicBoolean(); private final AtomicBoolean cancelled=new AtomicBoolean();
private volatile String status="Idle"; private volatile String status="Idle — /minegeo export <map name> to begin";
private volatile boolean busy; private volatile boolean busy;
private final Gson gson=new Gson(); private final Gson gson=new GsonBuilder().setPrettyPrinting().create();
@Override public void onEnable(){saveDefaultConfig();Objects.requireNonNull(getCommand("minegeo")).setExecutor(this);getLogger().info("MineGeo ready. /minegeo area <world> <minX> <minZ> <maxX> <maxZ>");} 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 void onDisable(){cancelled.set(true);}
@Override public boolean onCommand(CommandSender sender,Command command,String label,String[] args){ @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;} if(!sender.hasPermission("minegeo.admin")){sender.sendMessage("MineGeo: administrator permission required.");return true;}
try { try {
if(args.length==0){sender.sendMessage("MineGeo: area, export, status, cancel, link, upload");return true;} if(args.length==0||args[0].equalsIgnoreCase("help")) {sender.sendMessage(new String[]{"MineGeo commands:","/minegeo website <http(s)://address:port>","/minegeo link <dashboard token>","/minegeo area <world> <minX> <minZ> <maxX> <maxZ>","/minegeo export <map name> — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo status | cancel | reload"});return true;}
switch(args[0].toLowerCase(Locale.ROOT)){ 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> <minX> <minZ> <maxX> <maxZ>");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 "area" -> {
case "status" -> sender.sendMessage("MineGeo: "+status); requireIdle();if(args.length!=6)throw new IllegalArgumentException("Usage: /minegeo area <world> <minX> <minZ> <maxX> <maxZ>");
case "cancel" -> {cancelled.set(true);sender.sendMessage("MineGeo cancellation requested.");} World world=Bukkit.getWorld(args[1]);if(world==null||world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Select a loaded Overworld.");
case "export" -> export(sender,args.length>1?String.join(" ",Arrays.copyOfRange(args,1,args.length)):"Overworld Explorer"); 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)));
case "link" -> { if(args.length!=2)throw new IllegalArgumentException("link <account token from MineGeo dashboard>");getConfig().set("token",args[1]);saveConfig();sender.sendMessage("MineGeo contributor token saved. Restrict access to config.yml.");} 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.");
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.");} selectedWorld=world;minX=ax;minZ=az;maxX=bx;maxZ=bz;sender.sendMessage("MineGeo area selected: "+world.getName()+" "+ax+","+az+" to "+bx+","+bz);
default -> throw new IllegalArgumentException("Unknown command.");
} }
}catch(Exception ex){sender.sendMessage("MineGeo: "+ex.getMessage());} case "status" -> {sender.sendMessage("MineGeo: "+status);if(latest!=null)sender.sendMessage("Latest complete export: "+latest.toAbsolutePath());}
return true; 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 <http(s)://address:port>");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 <dashboard token>");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 "upload" -> upload(sender);
default -> throw new IllegalArgumentException("Unknown command. Use /minegeo help.");
}
}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 <token>.");
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 record Captured(int cx,int cz,ChunkSnapshot snapshot){}
private void export(CommandSender sender,String name)throws IOException{ private record Area(int minX,int minZ,int maxX,int maxZ){}
if(busy)throw new IllegalArgumentException("An export or upload is already running."); private void export(CommandSender sender,String name)throws IOException {
World world=selectedWorld!=null?selectedWorld:Bukkit.getWorlds().getFirst(); requireIdle();if(name.length()<3||name.length()>80)throw new IllegalArgumentException("Map name must contain 3–80 characters.");
if(world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Overworld only."); 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.");
busy=true;cancelled.set(false);List<int[]> coords=new ArrayList<>(); World world=selectedWorld!=null?selectedWorld:sender instanceof Player p?p.getWorld():Bukkit.getWorlds().stream().filter(w->w.getEnvironment()==World.Environment.NORMAL).findFirst().orElseThrow();
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(world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Overworld only. Select it with /minegeo area.");
if(coords.isEmpty()){busy=false;throw new IllegalArgumentException("No generated chunks in the selected area.");} Location center=sender instanceof Player p?p.getLocation():world.getSpawnLocation();
Path out=getDataFolder().toPath().resolve("export");Files.createDirectories(out); int half=Math.max(8,Math.min(256,getConfig().getInt("max-area-size",2048)/2));
List<Captured> captured=new ArrayList<>();int lower=world.getMinHeight(),upper=world.getMaxHeight(); Area area=selectedWorld==null?new Area(center.getBlockX()-half,center.getBlockZ()-half,center.getBlockX()+half,center.getBlockZ()+half):new Area(minX,minZ,maxX,maxZ);
sender.sendMessage("MineGeo snapshotting "+coords.size()+" generated chunks. No terrain will be generated."); Path out=getDataFolder().toPath().resolve("exports").resolve("export-"+System.currentTimeMillis());Files.createDirectories(out);
new BukkitRunnable(){int index; 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<Captured> queue=new ArrayBlockingQueue<>(4);AtomicBoolean done=new AtomicBoolean();AtomicReference<Exception> 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{ @Override public void run(){try{
if(cancelled.get()){status="Cancelled";busy=false;cancel();return;} if(cancelled.get()||failure.get()!=null||done.get()){done.set(true);cancel();return;}
int batch=Math.max(1,Math.min(4,getConfig().getInt("chunks-per-tick",1))); for(int n=0;n<batch&&cz<=lastZ&&queue.remainingCapacity()>0;n++){
for(int n=0;n<batch&&index<coords.size();n++,index++){int[] c=coords.get(index);if(!world.loadChunk(c[0],c[1],false))continue;Chunk chunk=world.getChunkAt(c[0],c[1]);captured.add(new Captured(c[0],c[1],chunk.getChunkSnapshot(true,true,false)));} 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);}}}
status="Snapshotting "+index+"/"+coords.size(); scanned++;if(++cx>lastX){cx=firstX;cz++;}
if(index>=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);}} 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); }.runTaskTimer(this,1,1);
} }
private void encode(List<Captured> chunks,Path out,String name,int lower,int upper){ private void encode(BlockingQueue<Captured> queue,AtomicBoolean done,AtomicReference<Exception> failure,Path out,String name,String serverName,Area area,int lower,int upper,int desired)throws Exception {
try{JsonObject bounds=new JsonObject();bounds.addProperty("minX",minX);bounds.addProperty("minZ",minZ);bounds.addProperty("maxX",maxX);bounds.addProperty("maxZ",maxZ); JsonObject bounds=new JsonObject();bounds.addProperty("minX",area.minX);bounds.addProperty("minZ",area.minZ);bounds.addProperty("maxX",area.maxX);bounds.addProperty("maxZ",area.maxZ);
JsonObject terrain=new JsonObject();terrain.add("bounds",bounds);JsonArray blocks=new JsonArray(),coverage=new JsonArray(),points=new JsonArray(); JsonArray coverage=new JsonArray(),points=new JsonArray();List<int[]> candidates=new ArrayList<>();long blocks=0;Path terrain=out.resolve("terrain.json.gz");
List<int[]> candidates=new ArrayList<>(); try(JsonWriter writer=new JsonWriter(new BufferedWriter(new OutputStreamWriter(new LimitedStream(new GZIPOutputStream(Files.newOutputStream(terrain))),StandardCharsets.UTF_8)))){
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); writer.beginObject().name("bounds");gson.toJson(bounds,writer);writer.name("blocks").beginArray();
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<minX||wx>=maxX||wz<minZ||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});}} 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.minX||wx>=area.maxX||wz<area.minZ||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+1<upper&&c.snapshot.getBlockType(x-1,y,z).isOccluding()&&c.snapshot.getBlockType(x+1,y,z).isOccluding()&&c.snapshot.getBlockType(x,y,z-1).isOccluding()&&c.snapshot.getBlockType(x,y,z+1).isOccluding()&&c.snapshot.getBlockType(x,y-1,z).isOccluding()&&c.snapshot.getBlockType(x,y+1,z).isOccluding())continue;
if(++blocks>5000000)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(candidates.size()<5)throw new IOException("Need at least five safe surface viewpoints; choose a larger area."); 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<upper&&ground.isOccluding()&&!ground.name().contains("LEAVES")&&c.snapshot.getBlockType(x,y+1,z).isAir()&&c.snapshot.getBlockType(x,y+2,z).isAir())candidates.add(new int[]{wx,y+1,wz});}
int desired=Math.min(100,Math.max(5,getConfig().getInt("viewpoints",12)));for(int i=0;i<Math.min(desired,candidates.size());i++){int[] p=candidates.get(i*candidates.size()/Math.min(desired,candidates.size()));JsonObject point=new JsonObject();point.addProperty("id","p"+i);point.addProperty("x",p[0]+.5);point.addProperty("y",p[1]+1.62);point.addProperty("z",p[2]+.5);points.add(point);} }
terrain.add("blocks",blocks);terrain.add("coverage",coverage);Path tmp=out.resolve("terrain.json.gz.tmp");try(Writer writer=new OutputStreamWriter(new GZIPOutputStream(Files.newOutputStream(tmp)),java.nio.charset.StandardCharsets.UTF_8)){gson.toJson(terrain,writer);}Files.move(tmp,out.resolve("terrain.json.gz"),StandardCopyOption.REPLACE_EXISTING); }
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 terrain exported from "+getConfig().getString("server-name"));manifest.addProperty("serverName",getConfig().getString("server-name"));manifest.addProperty("north","-z");manifest.add("bounds",bounds);manifest.add("viewpoints",points);JsonArray assets=new JsonArray();byte[] bytes=Files.readAllBytes(out.resolve("terrain.json.gz"));JsonObject asset=new JsonObject();asset.addProperty("path","terrain.json.gz");asset.addProperty("bytes",bytes.length);asset.addProperty("sha256",HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)));assets.add(asset);manifest.add("assets",assets);Files.writeString(out.resolve("manifest.json"),gson.toJson(manifest));status="Export complete: "+blocks.size()+" blocks, "+points.size()+" viewpoints. Edit manifest viewpoints if needed; /minegeo upload.";getLogger().info(status); if(failure.get()!=null)throw failure.get();if(cancelled.get())throw new IOException("Cancelled");writer.endArray().name("coverage");gson.toJson(coverage,writer);writer.endObject();
}catch(Exception ex){status="Export failed: "+ex.getMessage();getLogger().warning(status);}finally{busy=false;} }
if(coverage.isEmpty())throw new IOException("No generated chunks here. Select an explored area with /minegeo area.");
if(candidates.size()<5)throw new IOException("Need five safe, open surface positions. Select a larger land area (not ocean or treetops).");
List<int[]> chosen=new ArrayList<>();chosen.add(candidates.remove(candidates.size()/2));
while(chosen.size()<desired&&!candidates.isEmpty()){
if(cancelled.get())throw new IOException("Cancelled");int best=0;double bestDistance=-1;
for(int i=0;i<candidates.size();i++){int[] p=candidates.get(i);double distance=Double.MAX_VALUE;for(int[] q:chosen)distance=Math.min(distance,Math.pow(p[0]-q[0],2)+Math.pow(p[2]-q[2],2));if(distance>bestDistance){bestDistance=distance;best=i;}}chosen.add(candidates.remove(best));
}
for(int i=0;i<chosen.size();i++){int[] p=chosen.get(i);JsonObject point=new JsonObject();point.addProperty("id","p"+i);point.addProperty("x",p[0]+.5);point.addProperty("y",p[1]+1.62);point.addProperty("z",p[2]+.5);points.add(point);}
if(Files.size(terrain)>50L*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.");}
} }
} }

View File

@@ -6,30 +6,77 @@ import java.nio.file.*;
import java.io.*; import java.io.*;
import java.util.*; import java.util.*;
import java.time.Duration; 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 { public final class UploadClient {
private final String website,token; 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(); 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;} public UploadClient(String website,String 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();} URI uri;try{uri=URI.create(website==null?"":website.trim());}catch(Exception ex){throw new IllegalArgumentException("Set website to a full http:// or https:// address.");}
if(!List.of("http","https").contains(uri.getScheme())||uri.getHost()==null||uri.getUserInfo()!=null||uri.getQuery()!=null||uri.getFragment()!=null||!(uri.getPath().isEmpty()||uri.getPath().equals("/")))throw new IllegalArgumentException("Set website to http(s)://hostname:port, without a path. LAN addresses are supported.");
this.website=uri.toString().replaceAll("/$","");this.token=token==null?"":token.trim();
}
private JsonObject api(String path,String body)throws Exception{
var builder=HttpRequest.newBuilder(URI.create(website+"/api/v1"+path)).timeout(Duration.ofMinutes(2)).header("Content-Type","application/json");
if(!token.isBlank())builder.header("Authorization","Bearer "+token);
var request=body==null?builder.GET().build():builder.POST(HttpRequest.BodyPublishers.ofString(body)).build();
var response=client.send(request,HttpResponse.BodyHandlers.ofString());
if(response.statusCode()<200||response.statusCode()>=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 beginPairing()throws Exception{return api("/pairing","{}");}
public JsonObject pollPairing(String code)throws Exception{return api("/pairing/poll","{\"deviceCode\":\""+code+"\"}");} 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{ public void upload(Path directory)throws Exception{upload(directory,message->{},()->false);}
if(token==null||token.isBlank())throw new IOException("Link a contributor account first."); private void checkCancelled(BooleanSupplier cancelled)throws IOException{if(cancelled.getAsBoolean()||Thread.currentThread().isInterrupted())throw new IOException("Upload cancelled. Run upload again to resume.");}
JsonObject manifest=JsonParser.parseString(Files.readString(directory.resolve("manifest.json"))).getAsJsonObject(); 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<String> 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(); 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))); 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(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));} 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(); String datasetId=state.get("datasetId").getAsString();
JsonObject request=new JsonObject();request.addProperty("datasetId",datasetId);JsonObject signed=api("/uploads/sign",gson.toJson(request)); JsonObject existing=api("/datasets/"+datasetId,null);String stage=existing.get("state").getAsString();
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(!stage.equals("uploading")){
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(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.");
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));} progress.accept("Already uploaded — draft "+datasetId+" ("+stage+")");return;
try(RandomAccessFile input=new RandomAccessFile(file.toFile(),"r")){while(offset<length){input.seek(offset);byte[] chunk=new byte[(int)Math.min(6*1024*1024,length-offset)];input.readFully(chunk);var patched=client.send(HttpRequest.newBuilder(URI.create(uploadUrl)).timeout(Duration.ofMinutes(3)).header("Tus-Resumable","1.0.0").header("Content-Type","application/offset+octet-stream").header("Upload-Offset",Long.toString(offset)).header("x-signature",signedToken).method("PATCH",HttpRequest.BodyPublishers.ofByteArray(chunk)).build(),HttpResponse.BodyHandlers.ofString());if(patched.statusCode()!=204)throw new IOException("TUS patch failed at "+offset+": "+patched.statusCode()+". Run upload again to resume.");offset=Long.parseLong(patched.headers().firstValue("Upload-Offset").orElseThrow());}}
} }
api("/uploads/finalize","{\"datasetId\":\""+datasetId+"\"}"); 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<length){checkCancelled(cancelled);progress.accept("Uploading "+index+"/"+count+" · "+name+" · "+(100*offset/length)+"%");input.seek(offset);byte[] chunk=new byte[(int)Math.min(6*1024*1024,length-offset)];input.readFully(chunk);
var patched=client.send(HttpRequest.newBuilder(URI.create(uploadUrl)).timeout(Duration.ofMinutes(3)).header("Tus-Resumable","1.0.0").header("Content-Type","application/offset+octet-stream").header("Upload-Offset",Long.toString(offset)).header("x-signature",signedToken).header("apikey",publishableKey).method("PATCH",HttpRequest.BodyPublishers.ofByteArray(chunk)).build(),HttpResponse.BodyHandlers.ofString());
if(patched.statusCode()!=204)throw new IOException("Upload paused at "+offset+" bytes (HTTP "+patched.statusCode()+"). Run upload again to resume.");
long next=Long.parseLong(patched.headers().firstValue("Upload-Offset").orElseThrow());if(next!=offset+chunk.length)throw new IOException("Storage returned an unexpected offset. Retry upload to resume.");offset=next;
} }
private String b64(String s){return Base64.getEncoder().encodeToString(s.getBytes(java.nio.charset.StandardCharsets.UTF_8));} }
}
checkCancelled(cancelled);progress.accept("Upload complete; queueing panorama processing");api("/uploads/finalize",gson.toJson(body));
}
private String b64(String s){return Base64.getEncoder().encodeToString(s.getBytes(StandardCharsets.UTF_8));}
} }

View File

@@ -1,5 +1,5 @@
name: MineGeo name: MineGeo
version: 0.1.0 version: 0.1.1
main: dev.minegeo.plugin.MineGeoPlugin main: dev.minegeo.plugin.MineGeoPlugin
api-version: '1.21' api-version: '1.21'
description: Bounded terrain export for MineGeo panorama guessing description: Bounded terrain export for MineGeo panorama guessing

4
next-env.d.ts vendored
View File

@@ -1,7 +1,7 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts"; import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -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.

Binary file not shown.

View File

@@ -4,7 +4,8 @@ import { mkdir, copyFile } from "node:fs/promises";
const windows = process.platform === "win32"; const windows = process.platform === "win32";
const command = windows ? "gradlew.bat" : "sh"; const command = windows ? "gradlew.bat" : "sh";
const targets = process.argv.slice(2); 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) => { await new Promise((resolve, reject) => {
const child = spawn(command, windows ? targets : ["./gradlew", ...targets], { const child = spawn(command, windows ? targets : ["./gradlew", ...targets], {
cwd: "minecraft", cwd: "minecraft",

View File

@@ -29,5 +29,5 @@ const configured = Boolean(
console.log( console.log(
configured configured
? "Supabase configuration present. Apply the checked-in migration before starting." ? "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.",
); );

210
scripts/test-api.ts Normal file
View File

@@ -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<void>((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<number | null>((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);
}

232
scripts/test-plugin.ts Normal file
View File

@@ -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<void>((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<typeof setTimeout>;
}[] = [];
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<void>((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<void>((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<void>((r) => api.close(() => r()));
await writeFile(`${directory}/regression.log`, output);
}

View File

@@ -56,6 +56,23 @@ export const datasetSchema = z
.max(4096), .max(4096),
}) })
.superRefine((d, ctx) => { .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) if (new Set(d.viewpoints.map((p) => p.id)).size !== d.viewpoints.length)
ctx.addIssue({ code: "custom", message: "Viewpoint IDs must be unique" }); ctx.addIssue({ code: "custom", message: "Viewpoint IDs must be unique" });
if (new Set(d.assets.map((a) => a.path)).size !== d.assets.length) if (new Set(d.assets.map((a) => a.path)).size !== d.assets.length)

View File

@@ -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);
}
}

View File

@@ -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 }], assets: [{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 100 }],
}; };
assert.equal(datasetSchema.safeParse(base).success, true); 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( assert.equal(
datasetSchema.safeParse({ ...base, viewpoints: base.viewpoints.slice(1) }) datasetSchema.safeParse({ ...base, viewpoints: base.viewpoints.slice(1) })
.success, .success,

View File

@@ -1,4 +1,5 @@
import { createClient } from "@supabase/supabase-js"; import { createClient } from "@supabase/supabase-js";
import { existsSync } from "node:fs";
import { randomUUID, createHash } from "node:crypto"; import { randomUUID, createHash } from "node:crypto";
import { mkdir, writeFile, readFile, readdir, rm } from "node:fs/promises"; import { mkdir, writeFile, readFile, readdir, rm } from "node:fs/promises";
import { gunzipSync } from "node:zlib"; import { gunzipSync } from "node:zlib";
@@ -6,6 +7,7 @@ import sharp from "sharp";
import { datasetSchema, faces, type TerrainSnapshot } from "../shared/protocol"; import { datasetSchema, faces, type TerrainSnapshot } from "../shared/protocol";
import { renderSnapshot } from "../renderer/render.mjs"; import { renderSnapshot } from "../renderer/render.mjs";
import type { Database } from "../shared/database"; import type { Database } from "../shared/database";
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
const client = createClient<Database>( const client = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!, process.env.SUPABASE_SECRET_KEY!,