Refresh the homepage and improve export and draft guidance
This commit is contained in:
@@ -12,7 +12,5 @@ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
|
||||
SUPABASE_SECRET_KEY=
|
||||
# Optional direct PostgreSQL connection for maintenance. Runtime uses the Supabase API.
|
||||
DATABASE_URL=
|
||||
# Explicit local guest sandbox when no hosted project is configured.
|
||||
MINEGEO_LOCAL_MODE=true
|
||||
MINEGEO_FACE_SIZE=1024
|
||||
MINEGEO_MAX_UPLOAD_BYTES=268435456
|
||||
|
||||
57
README.md
Normal file
57
README.md
Normal 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
918
app/block-theme.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -77,13 +77,20 @@ export default function Downloads() {
|
||||
<p className="muted">The plugin build is not available yet.</p>
|
||||
)}
|
||||
<pre>
|
||||
/minegeo area world -256 -256 256 256{"\n"}/minegeo export My
|
||||
Overworld{"\n"}/minegeo link <dashboard token>{"\n"}/minegeo
|
||||
upload
|
||||
{
|
||||
"/minegeo website https://your-minegeo-site.example\n/minegeo link <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"
|
||||
}
|
||||
</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 }}>
|
||||
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>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import "./block-theme.css";
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "MineGeo — Get lost in a familiar world",
|
||||
|
||||
276
app/page.tsx
276
app/page.tsx
@@ -2,190 +2,246 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
Compass,
|
||||
Cuboid,
|
||||
Map,
|
||||
MapPin,
|
||||
Move,
|
||||
Timer,
|
||||
Blocks,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { Header, Footer, EmptyState } from "@/components/Shell";
|
||||
import { Header, Footer } from "@/components/Shell";
|
||||
import { MapCard } from "@/components/MapCard";
|
||||
import { catalog } from "@/lib/catalog";
|
||||
export const dynamic = "force-dynamic";
|
||||
export default async function Home() {
|
||||
const maps = await catalog();
|
||||
const serverCount = new Set(maps.map((m) => m.server_id)).size;
|
||||
const locations = maps.reduce((n, m) => n + m.panorama_count, 0);
|
||||
const servers = new Set(maps.map((map) => map.server_id)).size;
|
||||
const locations = maps.reduce((total, map) => total + map.panorama_count, 0);
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<main className="home-page">
|
||||
<section className="hero">
|
||||
<div className="hero-grid" />
|
||||
<div className="hero-copy">
|
||||
<div className="status-label">
|
||||
<span className="live-dot" /> MINECRAFT WORLDS. REAL EXPLORATION.
|
||||
<span className="live-dot" /> MINECRAFT · EXPLORE · GUESS
|
||||
</div>
|
||||
<h1>
|
||||
Get lost in a<br />
|
||||
<span>familiar world.</span>
|
||||
Guess where you are
|
||||
<br />
|
||||
in a <span>blocky world.</span>
|
||||
</h1>
|
||||
<p>
|
||||
Somewhere between the blocks, there’s a place you know.
|
||||
<br className="desktop" /> Look around. Trust your instincts. Make
|
||||
your guess.
|
||||
Drop into a random corner of a Minecraft world.
|
||||
<br className="desktop" /> Look around, spot the landmarks, and
|
||||
place your pin
|
||||
<br className="desktop" /> before the clock runs out.
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<Link href="/servers" className="button primary">
|
||||
Find your next world <ArrowRight size={18} />
|
||||
</Link>
|
||||
<Link href="/about" className="text-link">
|
||||
How it works <ArrowUpRight size={15} />
|
||||
Start exploring <ArrowRight size={18} />
|
||||
</Link>
|
||||
<span className="hero-mode-note">3 difficulty levels</span>
|
||||
</div>
|
||||
<div className="hero-notes">
|
||||
<span>
|
||||
<Compass size={15} /> 360° panoramas
|
||||
</span>
|
||||
<span>
|
||||
<Move size={15} /> No roads. No moving.
|
||||
</span>
|
||||
<span>
|
||||
<Cuboid size={15} /> Just you and the blocks.
|
||||
<span className="mode-dots" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<span>Five rounds. No roads. Just your instincts.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
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="hero-art" aria-label="Minecraft surface map">
|
||||
<div className="map-art-frame">
|
||||
<Image
|
||||
src="/images/example-map.webp"
|
||||
alt="Top-down map of the real Paper renderer test world"
|
||||
src={maps[0]?.mapImage || "/images/example-map.webp"}
|
||||
alt="A Minecraft surface map framed in parchment"
|
||||
width={1024}
|
||||
height={1024}
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
<div className="map-grid" />
|
||||
<div className="map-pin">
|
||||
<MapPin size={38} fill="currentColor" />
|
||||
<span className="square-pin pin-green" />
|
||||
<span className="square-pin pin-red" />
|
||||
<span className="square-pin pin-gold" />
|
||||
</div>
|
||||
<div className="map-corner top-left" />
|
||||
<div className="map-corner bottom-right" />
|
||||
</div>
|
||||
<span className="compass-art">
|
||||
<Compass size={42} />
|
||||
<span className="compass-art" aria-hidden="true">
|
||||
<Compass size={43} />
|
||||
<small>N</small>
|
||||
</span>
|
||||
<div className="floating-tag">
|
||||
<MapPin size={15} /> ONE WORLD. COUNTLESS POSSIBILITIES.
|
||||
<MapPin size={14} />
|
||||
<strong>X: ??? Z: ???</strong>
|
||||
<span>Overworld</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="grass-divider">
|
||||
<div className="grass-divider" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
<section className="world-stats">
|
||||
<section className="world-stats" aria-label="Community statistics">
|
||||
<div>
|
||||
<strong>{serverCount}</strong>
|
||||
<i className="stat-cube green-cube" />
|
||||
<div>
|
||||
<strong>{servers.toLocaleString()}</strong>
|
||||
<span>COMMUNITY SERVERS</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{maps.length}</strong>
|
||||
<span>WORLDS TO EXPLORE</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{locations}</strong>
|
||||
<span>PLACES TO GET LOST</span>
|
||||
<i className="stat-cube gold-cube" />
|
||||
<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 className="stat-note">
|
||||
<span className="live-dot" />
|
||||
Built by explorers.
|
||||
<br />
|
||||
For explorers.
|
||||
</div>
|
||||
</section>
|
||||
<section className="section catalog-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="eyebrow">PICK A WORLD. DROP A PIN.</span>
|
||||
<h2>Where to today?</h2>
|
||||
<span className="eyebrow">COMMUNITY MAPS</span>
|
||||
<h2>Where will you go today?</h2>
|
||||
</div>
|
||||
<Link className="text-link" href="/servers">
|
||||
All worlds <ArrowRight size={16} />
|
||||
All {maps.length} maps <ArrowRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
{maps.length ? (
|
||||
<div className="cards-grid">
|
||||
{maps.slice(0, 3).map((m, i) => (
|
||||
<MapCard map={m} index={i} key={m.id} />
|
||||
{maps.slice(0, 4).map((map, index) => (
|
||||
<MapCard map={map} index={index} key={map.id} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="The world is yours to map">
|
||||
The catalog is waiting for its first approved community map.
|
||||
Capture a world with our mod or server plugin.
|
||||
</EmptyState>
|
||||
)}
|
||||
</section>
|
||||
<section className="section journey">
|
||||
<div className="journey-intro">
|
||||
<span className="eyebrow">A NEW WAY TO SEE MINECRAFT</span>
|
||||
<h2>
|
||||
Every block
|
||||
<br />
|
||||
tells a story.
|
||||
</h2>
|
||||
<div className="first-map-panel">
|
||||
<div className="empty-map-art" aria-hidden="true">
|
||||
<Image
|
||||
src="/images/example-map.webp"
|
||||
alt=""
|
||||
width={180}
|
||||
height={180}
|
||||
unoptimized
|
||||
/>
|
||||
<span>YOUR WORLD HERE</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="eyebrow">
|
||||
THE FIRST EXPEDITION STARTS WITH YOU
|
||||
</span>
|
||||
<h3>Put your server on the map.</h3>
|
||||
<p>
|
||||
That mountain. That oddly placed tower. That very suspicious dirt
|
||||
hut. You’ve been here before… right?
|
||||
No community maps have been published yet. Export your world
|
||||
with the server plugin, or capture five locations with the
|
||||
Fabric mod. Preview your map and submit it for review.
|
||||
</p>
|
||||
<Link href="/about" className="text-link">
|
||||
Learn how to play <ArrowRight size={16} />
|
||||
<Link className="button primary" href="/downloads">
|
||||
<Upload size={16} /> Get the capture tools
|
||||
</Link>
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
<span className="eyebrow">YOUR SERVER DESERVES TO BE EXPLORED</span>
|
||||
<h2>Put your world on the map.</h2>
|
||||
<h3>Pick your world</h3>
|
||||
<p>Choose a community server, map, and difficulty.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span>02</span>
|
||||
<div>
|
||||
<h3>Look for the clues</h3>
|
||||
<p>
|
||||
Bring your builds, your landscapes, and your community to MineGeo.
|
||||
Rotate the panorama. Find builds, biomes, and landmarks.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/downloads" className="button primary">
|
||||
Create a map <ArrowUpRight size={18} />
|
||||
</li>
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
11
artifacts-api-smoke.json
Normal file
11
artifacts-api-smoke.json
Normal 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"
|
||||
]
|
||||
}
|
||||
16
artifacts-plugin-regression.json
Normal file
16
artifacts-plugin-regression.json
Normal 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"
|
||||
}
|
||||
@@ -9,6 +9,7 @@ type Draft = {
|
||||
description: string;
|
||||
state: string;
|
||||
source: string;
|
||||
rejection_reason: string | null;
|
||||
map: string | null;
|
||||
manifest: DatasetManifest;
|
||||
previews: { id: string; image: string | null }[];
|
||||
@@ -22,14 +23,27 @@ export function DraftEditor({ id }: { id: string }) {
|
||||
[message, setMessage] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const load = () =>
|
||||
api<Draft>("datasets/" + id)
|
||||
.then((d) => {
|
||||
if (disposed) return;
|
||||
setDraft(d);
|
||||
setName(d.name);
|
||||
setDescription(d.description);
|
||||
setSelected(d.manifest.viewpoints.map((p) => p.id));
|
||||
if (["uploading", "processing"].includes(d.state))
|
||||
timer = setTimeout(load, 5000);
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
.catch((e) => {
|
||||
if (!disposed) setError(e.message);
|
||||
});
|
||||
load();
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [id]);
|
||||
async function save(submit = false) {
|
||||
setBusy(true);
|
||||
@@ -55,6 +69,11 @@ export function DraftEditor({ id }: { id: string }) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api("datasets/" + id + "/cancel", {});
|
||||
setDraft((d) =>
|
||||
d
|
||||
? { ...d, state: "failed", rejection_reason: "Processing cancelled" }
|
||||
: d,
|
||||
);
|
||||
setMessage("Processing cancelled.");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -70,6 +89,18 @@ export function DraftEditor({ id }: { id: string }) {
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{draft.source === "plugin"
|
||||
? "Server-generated views use vanilla terrain and simplified lighting. Entities, shaders, custom packs and dynamic sign text are omitted."
|
||||
|
||||
@@ -17,12 +17,12 @@ export function Header() {
|
||||
<div className="header-inner">
|
||||
<Brand />
|
||||
<nav aria-label="Main navigation">
|
||||
<Link href="/servers">Explore servers</Link>
|
||||
<Link href="/downloads">Create a map</Link>
|
||||
<Link href="/about">How to play</Link>
|
||||
<Link href="/servers">Play & maps</Link>
|
||||
<Link href="/downloads">Downloads</Link>
|
||||
<Link href="/about">Guide</Link>
|
||||
</nav>
|
||||
<Link href="/dashboard" className="header-account">
|
||||
My expedition <ArrowUpRight size={15} />
|
||||
My account <ArrowUpRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
61
docs/server-plugin.md
Normal file
61
docs/server-plugin.md
Normal 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
22
docs/verification.md
Normal 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`.
|
||||
27
lib/api.ts
27
lib/api.ts
@@ -294,7 +294,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
.single(),
|
||||
);
|
||||
const endpoint = new URL(
|
||||
"/storage/v1/upload/resumable",
|
||||
"/storage/v1/upload/resumable/sign",
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
);
|
||||
endpoint.hostname = endpoint.hostname.replace(
|
||||
@@ -315,6 +315,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
path: a.path,
|
||||
objectName,
|
||||
token: signed.token,
|
||||
apiKey: process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
|
||||
endpoint: endpoint.toString(),
|
||||
};
|
||||
}),
|
||||
@@ -368,7 +369,9 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
previews: await Promise.all(
|
||||
manifest.viewpoints.map(async (p: { id: string }) => ({
|
||||
id: p.id,
|
||||
image: await mediaUrl(`${d.id}/${p.id}/preview.webp`),
|
||||
image: d.map_path
|
||||
? await mediaUrl(`${d.id}/${p.id}/preview.webp`)
|
||||
: null,
|
||||
})),
|
||||
),
|
||||
});
|
||||
@@ -441,20 +444,28 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (method === "POST" && path[2] === "cancel") {
|
||||
required(
|
||||
await client
|
||||
if (!["uploading", "processing"].includes(d.state))
|
||||
throw new Error("This dataset is not uploading or processing");
|
||||
const cancelled = await client
|
||||
.from("upload_jobs")
|
||||
.update({ state: "cancelled" })
|
||||
.eq("dataset_id", d.id)
|
||||
.in("state", ["queued", "running"])
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
.maybeSingle();
|
||||
if (cancelled.error) throw new Error(cancelled.error.message);
|
||||
required(
|
||||
await client
|
||||
.from("datasets")
|
||||
.update({ state: "failed", rejection_reason: "Processing cancelled" })
|
||||
.update({
|
||||
state: "failed",
|
||||
rejection_reason: "Processing cancelled",
|
||||
})
|
||||
.eq("id", d.id)
|
||||
.in("state", ["uploading", "processing"]);
|
||||
.in("state", ["uploading", "processing"])
|
||||
.select("id")
|
||||
.single(),
|
||||
);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ export async function catalog() {
|
||||
return Promise.all(
|
||||
maps.map(async (m) => ({
|
||||
...m,
|
||||
image: (await mediaUrl(m.cover_path)) ?? undefined,
|
||||
image: (await mediaUrl(m.map_path || m.cover_path)) ?? undefined,
|
||||
mapImage: (await mediaUrl(m.map_path)) ?? undefined,
|
||||
server: servers.find((s) => s.id === m.server_id),
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -1 +1 @@
|
||||
allprojects { group = 'dev.minegeo'; version = '0.1.0'; repositories { mavenCentral() } }
|
||||
allprojects { group = 'dev.minegeo'; version = '0.1.1'; repositories { mavenCentral() } }
|
||||
|
||||
@@ -17,6 +17,6 @@ public final class CaptureScreen extends Screen {
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("New map at this location"),b->{mod.newMap();init(width,height);}).dimensions(x,182,260,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Close"),b->close()).dimensions(x,210,260,20).build());
|
||||
}
|
||||
@Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Website (HTTPS outside localhost)",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,242,0xffffffff);int y=264;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}}
|
||||
@Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Website URL (HTTP for LAN testing)",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,242,0xffffffff);int y=264;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}}
|
||||
@Override public boolean shouldPause(){return false;}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public final class MineGeoClient implements ClientModInitializer {
|
||||
public static MineGeoClient INSTANCE;
|
||||
public final ExecutorService background=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"MineGeo upload");t.setDaemon(true);return t;});
|
||||
public final Gson gson=new GsonBuilder().setPrettyPrinting().create();
|
||||
public String website="http://localhost:3000",token="",status="Explore normally to collect surface coverage.";
|
||||
public volatile String website="http://localhost:3000",token="",status="Explore normally to collect surface coverage.";
|
||||
public volatile boolean uploading=false;public SurfaceDataset dataset;private SurfaceDataset.Point pending;
|
||||
private Path config;
|
||||
@Override public void onInitializeClient(){INSTANCE=this;config=MinecraftClient.getInstance().runDirectory.toPath().resolve("config/minegeo.json");try{if(Files.exists(config)){var j=JsonParser.parseString(Files.readString(config)).getAsJsonObject();website=j.get("website").getAsString();token=j.get("token").getAsString();}}catch(Exception e){message("Could not read MineGeo configuration");}
|
||||
@@ -31,5 +31,5 @@ public final class MineGeoClient implements ClientModInitializer {
|
||||
public void savedCapture(Path path){try{if(pending==null||!path.getFileName().toString().equals(pending.id()))return;dataset.points.add(pending);dataset.save();message("Panorama saved locally ("+dataset.points.size()+" total). Press G for preview or upload.");}catch(Exception e){message("Saving capture failed: "+e.getMessage());}finally{pending=null;}}
|
||||
public void newMap(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");dataset=SurfaceDataset.create(server());message("New 512 × 512 map started. Older maps remain in .minecraft/minegeo.");}catch(Exception e){message(e.getMessage());}}
|
||||
public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient(website,"");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();MinecraftClient.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getOperatingSystem().open(link);});for(int i=0;i<120;i++){Thread.sleep(5000);var response=uploader.pollPairing(code);if(response.get("status").getAsString().equals("approved")){token=response.get("token").getAsString();saveConfig();MinecraftClient.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});}
|
||||
public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;if(selected==null||selected.points.size()<5){message("Capture at least five locations before uploading.");return;}uploading=true;background.execute(()->{try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(website,token).upload(selected.directory);MinecraftClient.getInstance().execute(()->message("Upload received. Preview and submit the private draft in your dashboard."));}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});}
|
||||
public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;if(selected==null||selected.points.size()<5){message("Capture at least five locations before uploading.");return;}uploading=true;background.execute(()->{try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(website,token).upload(selected.directory, progress -> status=progress, () -> false);MinecraftClient.getInstance().execute(()->message("Upload received. Preview and submit the private draft in your dashboard."));}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,144 @@
|
||||
package dev.minegeo.plugin;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.command.*;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.scheduler.BukkitRunnable;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.*;
|
||||
import java.util.logging.Level;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
public final class MineGeoPlugin extends JavaPlugin {
|
||||
private World selectedWorld;
|
||||
private int minX=-256,minZ=-256,maxX=256,maxZ=256;
|
||||
private final AtomicBoolean cancelled=new AtomicBoolean();
|
||||
private volatile String status="Idle";
|
||||
private volatile String status="Idle — /minegeo export <map name> to begin";
|
||||
private volatile boolean busy;
|
||||
private final Gson gson=new Gson();
|
||||
@Override public void onEnable(){saveDefaultConfig();Objects.requireNonNull(getCommand("minegeo")).setExecutor(this);getLogger().info("MineGeo ready. /minegeo area <world> <minX> <minZ> <maxX> <maxZ>");}
|
||||
private final Gson gson=new GsonBuilder().setPrettyPrinting().create();
|
||||
private volatile Path latest;
|
||||
@Override public void onEnable(){
|
||||
saveDefaultConfig();Objects.requireNonNull(getCommand("minegeo")).setExecutor(this);
|
||||
try {
|
||||
Files.createDirectories(getDataFolder().toPath().resolve("exports"));
|
||||
Path root=getDataFolder().toPath().toAbsolutePath().normalize(),pointer=root.resolve("latest-export.txt");
|
||||
if(Files.exists(pointer)) {Path candidate=root.resolve(Files.readString(pointer).trim()).normalize();if(candidate.startsWith(root)&&Files.isRegularFile(candidate.resolve("manifest.json")))latest=candidate;}
|
||||
Path legacy=root.resolve("export");if(latest==null&&Files.isRegularFile(legacy.resolve("manifest.json")))latest=legacy;
|
||||
}catch(IOException ex){getLogger().log(Level.WARNING,"Cannot prepare export folder",ex);}
|
||||
getLogger().info("MineGeo ready. /minegeo help — exports: "+getDataFolder().toPath().resolve("exports").toAbsolutePath());
|
||||
}
|
||||
@Override public void onDisable(){cancelled.set(true);}
|
||||
@Override public boolean onCommand(CommandSender sender,Command command,String label,String[] args){
|
||||
if(!sender.hasPermission("minegeo.admin")){sender.sendMessage("MineGeo: administrator permission required.");return true;}
|
||||
try {
|
||||
if(args.length==0){sender.sendMessage("MineGeo: area, export, status, cancel, link, upload");return true;}
|
||||
switch(args[0].toLowerCase(Locale.ROOT)){
|
||||
case "area" -> { if(busy)throw new IllegalArgumentException("Cancel the current export before changing the area.");if(args.length!=6)throw new IllegalArgumentException("area <world> <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 "status" -> sender.sendMessage("MineGeo: "+status);
|
||||
case "cancel" -> {cancelled.set(true);sender.sendMessage("MineGeo cancellation requested.");}
|
||||
case "export" -> export(sender,args.length>1?String.join(" ",Arrays.copyOfRange(args,1,args.length)):"Overworld Explorer");
|
||||
case "link" -> { if(args.length!=2)throw new IllegalArgumentException("link <account token from MineGeo dashboard>");getConfig().set("token",args[1]);saveConfig();sender.sendMessage("MineGeo contributor token saved. Restrict access to config.yml.");}
|
||||
case "upload" -> {if(busy)throw new IllegalArgumentException("Wait for the current operation.");busy=true;Bukkit.getScheduler().runTaskAsynchronously(this,()->{try{status="Uploading";new dev.minegeo.plugin.UploadClient(getConfig().getString("website"),getConfig().getString("token")).upload(getDataFolder().toPath().resolve("export"));status="Upload complete; review the private draft on MineGeo.";}catch(Exception ex){status="Upload failed: "+ex.getMessage();getLogger().warning(status);}finally{busy=false;}});sender.sendMessage("MineGeo upload started.");}
|
||||
default -> throw new IllegalArgumentException("Unknown command.");
|
||||
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)) {
|
||||
case "area" -> {
|
||||
requireIdle();if(args.length!=6)throw new IllegalArgumentException("Usage: /minegeo area <world> <minX> <minZ> <maxX> <maxZ>");
|
||||
World world=Bukkit.getWorld(args[1]);if(world==null||world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Select a loaded Overworld.");
|
||||
int ax=Integer.parseInt(args[2]),az=Integer.parseInt(args[3]),bx=Integer.parseInt(args[4]),bz=Integer.parseInt(args[5]);int cap=Math.max(16,Math.min(2048,getConfig().getInt("max-area-size",2048)));
|
||||
if(bx<=ax||bz<=az||(long)bx-ax>cap||(long)bz-az>cap||Math.abs((long)ax)>30000000||Math.abs((long)az)>30000000||Math.abs((long)bx)>30000000||Math.abs((long)bz)>30000000)throw new IllegalArgumentException("Invalid bounds; maximum "+cap+" blocks per side, within the world border.");
|
||||
selectedWorld=world;minX=ax;minZ=az;maxX=bx;maxZ=bz;sender.sendMessage("MineGeo area selected: "+world.getName()+" "+ax+","+az+" to "+bx+","+bz);
|
||||
}
|
||||
}catch(Exception ex){sender.sendMessage("MineGeo: "+ex.getMessage());}
|
||||
return true;
|
||||
case "status" -> {sender.sendMessage("MineGeo: "+status);if(latest!=null)sender.sendMessage("Latest complete export: "+latest.toAbsolutePath());}
|
||||
case "cancel" -> {cancelled.set(true);sender.sendMessage(busy?"MineGeo cancellation requested. Completed exports are kept.":"MineGeo: no operation is running.");}
|
||||
case "reload" -> {requireIdle();reloadConfig();sender.sendMessage("MineGeo configuration reloaded.");}
|
||||
case "website" -> {requireIdle();if(args.length!=2)throw new IllegalArgumentException("Usage: /minegeo website <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 void export(CommandSender sender,String name)throws IOException{
|
||||
if(busy)throw new IllegalArgumentException("An export or upload is already running.");
|
||||
World world=selectedWorld!=null?selectedWorld:Bukkit.getWorlds().getFirst();
|
||||
if(world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Overworld only.");
|
||||
busy=true;cancelled.set(false);List<int[]> coords=new ArrayList<>();
|
||||
for(int cz=Math.floorDiv(minZ,16);cz<=Math.floorDiv(maxZ-1,16);cz++)for(int cx=Math.floorDiv(minX,16);cx<=Math.floorDiv(maxX-1,16);cx++)if(world.isChunkGenerated(cx,cz))coords.add(new int[]{cx,cz});
|
||||
if(coords.isEmpty()){busy=false;throw new IllegalArgumentException("No generated chunks in the selected area.");}
|
||||
Path out=getDataFolder().toPath().resolve("export");Files.createDirectories(out);
|
||||
List<Captured> captured=new ArrayList<>();int lower=world.getMinHeight(),upper=world.getMaxHeight();
|
||||
sender.sendMessage("MineGeo snapshotting "+coords.size()+" generated chunks. No terrain will be generated.");
|
||||
new BukkitRunnable(){int index;
|
||||
private record Area(int minX,int minZ,int maxX,int maxZ){}
|
||||
private void export(CommandSender sender,String name)throws IOException {
|
||||
requireIdle();if(name.length()<3||name.length()>80)throw new IllegalArgumentException("Map name must contain 3–80 characters.");
|
||||
String serverName=getConfig().getString("server-name","My Minecraft server");if(serverName.length()<2||serverName.length()>80)throw new IllegalArgumentException("server-name in config.yml must contain 2–80 characters.");
|
||||
World world=selectedWorld!=null?selectedWorld:sender instanceof Player p?p.getWorld():Bukkit.getWorlds().stream().filter(w->w.getEnvironment()==World.Environment.NORMAL).findFirst().orElseThrow();
|
||||
if(world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Overworld only. Select it with /minegeo area.");
|
||||
Location center=sender instanceof Player p?p.getLocation():world.getSpawnLocation();
|
||||
int half=Math.max(8,Math.min(256,getConfig().getInt("max-area-size",2048)/2));
|
||||
Area area=selectedWorld==null?new Area(center.getBlockX()-half,center.getBlockZ()-half,center.getBlockX()+half,center.getBlockZ()+half):new Area(minX,minZ,maxX,maxZ);
|
||||
Path out=getDataFolder().toPath().resolve("exports").resolve("export-"+System.currentTimeMillis());Files.createDirectories(out);
|
||||
int lower=world.getMinHeight(),upper=world.getMaxHeight(),desired=Math.min(100,Math.max(5,getConfig().getInt("viewpoints",12))),batch=Math.max(1,Math.min(4,getConfig().getInt("chunks-per-tick",1)));
|
||||
ArrayBlockingQueue<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{
|
||||
if(cancelled.get()){status="Cancelled";busy=false;cancel();return;}
|
||||
int batch=Math.max(1,Math.min(4,getConfig().getInt("chunks-per-tick",1)));
|
||||
for(int n=0;n<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)));}
|
||||
status="Snapshotting "+index+"/"+coords.size();
|
||||
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);}}
|
||||
if(cancelled.get()||failure.get()!=null||done.get()){done.set(true);cancel();return;}
|
||||
for(int n=0;n<batch&&cz<=lastZ&&queue.remainingCapacity()>0;n++){
|
||||
if(world.isChunkGenerated(cx,cz)){boolean wasLoaded=world.isChunkLoaded(cx,cz);if(world.loadChunk(cx,cz,false)){try{queue.add(new Captured(cx,cz,world.getChunkAt(cx,cz).getChunkSnapshot(true,true,false)));}finally{if(!wasLoaded)world.unloadChunkRequest(cx,cz);}}}
|
||||
scanned++;if(++cx>lastX){cx=firstX;cz++;}
|
||||
}
|
||||
status="Exporting chunks "+scanned+"/"+total+" — "+out.getFileName();if(cz>lastZ){done.set(true);cancel();}
|
||||
}catch(Exception ex){failure.set(ex);done.set(true);cancel();}}
|
||||
}.runTaskTimer(this,1,1);
|
||||
}
|
||||
private void encode(List<Captured> chunks,Path out,String name,int lower,int upper){
|
||||
try{JsonObject bounds=new JsonObject();bounds.addProperty("minX",minX);bounds.addProperty("minZ",minZ);bounds.addProperty("maxX",maxX);bounds.addProperty("maxZ",maxZ);
|
||||
JsonObject terrain=new JsonObject();terrain.add("bounds",bounds);JsonArray blocks=new JsonArray(),coverage=new JsonArray(),points=new JsonArray();
|
||||
List<int[]> candidates=new ArrayList<>();
|
||||
for(Captured c:chunks){if(cancelled.get())throw new IOException("Cancelled");JsonArray cv=new JsonArray();cv.add(c.cx);cv.add(c.cz);coverage.add(cv);
|
||||
for(int z=0;z<16;z++)for(int x=0;x<16;x++){int wx=c.cx*16+x,wz=c.cz*16+z;if(wx<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});}}
|
||||
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 {
|
||||
JsonObject bounds=new JsonObject();bounds.addProperty("minX",area.minX);bounds.addProperty("minZ",area.minZ);bounds.addProperty("maxX",area.maxX);bounds.addProperty("maxZ",area.maxZ);
|
||||
JsonArray coverage=new JsonArray(),points=new JsonArray();List<int[]> candidates=new ArrayList<>();long blocks=0;Path terrain=out.resolve("terrain.json.gz");
|
||||
try(JsonWriter writer=new JsonWriter(new BufferedWriter(new OutputStreamWriter(new LimitedStream(new GZIPOutputStream(Files.newOutputStream(terrain))),StandardCharsets.UTF_8)))){
|
||||
writer.beginObject().name("bounds");gson.toJson(bounds,writer);writer.name("blocks").beginArray();
|
||||
while(!done.get()||!queue.isEmpty()){
|
||||
if(cancelled.get())throw new IOException("Cancelled");if(failure.get()!=null)throw failure.get();Captured c=queue.poll(250,TimeUnit.MILLISECONDS);if(c==null)continue;
|
||||
JsonArray cv=new JsonArray();cv.add(c.cx);cv.add(c.cz);coverage.add(cv);
|
||||
for(int z=0;z<16;z++)for(int x=0;x<16;x++){
|
||||
int wx=c.cx*16+x,wz=c.cz*16+z;if(wx<area.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.");
|
||||
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);
|
||||
}catch(Exception ex){status="Export failed: "+ex.getMessage();getLogger().warning(status);}finally{busy=false;}
|
||||
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});}
|
||||
}
|
||||
}
|
||||
if(failure.get()!=null)throw failure.get();if(cancelled.get())throw new IOException("Cancelled");writer.endArray().name("coverage");gson.toJson(coverage,writer);writer.endObject();
|
||||
}
|
||||
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.");}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,30 +6,77 @@ import java.nio.file.*;
|
||||
import java.io.*;
|
||||
import java.util.*;
|
||||
import java.time.Duration;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.function.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** TUS resumable upload client shared by plugin and Fabric build. */
|
||||
/** Shared TUS client. State is saved beside each completed local dataset. */
|
||||
public final class UploadClient {
|
||||
private final String website,token;
|
||||
private final HttpClient client=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build();
|
||||
// Next's HTTP server does not support Java's cleartext HTTP/2 upgrade handshake.
|
||||
private final HttpClient client=HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).connectTimeout(Duration.ofSeconds(30)).build();
|
||||
private final Gson gson=new Gson();
|
||||
public UploadClient(String website,String token){if(website==null||(!website.startsWith("https://")&&!website.startsWith("http://localhost")&&!website.startsWith("http://127.0.0.1")))throw new IllegalArgumentException("Use HTTPS or local development URL");this.website=website.replaceAll("/$","");this.token=token;}
|
||||
private JsonObject api(String path,String body)throws Exception{var request=HttpRequest.newBuilder(URI.create(website+"/api/v1"+path)).timeout(Duration.ofMinutes(2)).header("Authorization","Bearer "+token).header("Content-Type","application/json").POST(HttpRequest.BodyPublishers.ofString(body)).build();var response=client.send(request,HttpResponse.BodyHandlers.ofString());if(response.statusCode()>=400)throw new IOException("MineGeo API "+response.statusCode()+": "+response.body());return JsonParser.parseString(response.body()).getAsJsonObject();}
|
||||
public UploadClient(String website,String token){
|
||||
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 pollPairing(String code)throws Exception{return api("/pairing/poll","{\"deviceCode\":\""+code+"\"}");}
|
||||
public void upload(Path directory)throws Exception{
|
||||
if(token==null||token.isBlank())throw new IOException("Link a contributor account first.");
|
||||
JsonObject manifest=JsonParser.parseString(Files.readString(directory.resolve("manifest.json"))).getAsJsonObject();
|
||||
public JsonObject pollPairing(String code)throws Exception{var body=new JsonObject();body.addProperty("deviceCode",code);return api("/pairing/poll",gson.toJson(body));}
|
||||
public void upload(Path directory)throws Exception{upload(directory,message->{},()->false);}
|
||||
private void checkCancelled(BooleanSupplier cancelled)throws IOException{if(cancelled.getAsBoolean()||Thread.currentThread().isInterrupted())throw new IOException("Upload cancelled. Run upload again to resume.");}
|
||||
private void save(Path ledger,JsonObject state)throws IOException{Path tmp=ledger.resolveSibling(ledger.getFileName()+".tmp");Files.writeString(tmp,gson.toJson(state));Files.move(tmp,ledger,StandardCopyOption.REPLACE_EXISTING);}
|
||||
private String hash(byte[] bytes)throws Exception{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}
|
||||
public void upload(Path directory,Consumer<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();
|
||||
String manifestHash=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(gson.toJson(manifest).getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
if(!state.has("hash")||!state.get("hash").getAsString().equals(manifestHash)){state=new JsonObject();state.addProperty("hash",manifestHash);JsonObject request=new JsonObject();request.add("manifest",manifest);JsonObject created=api("/uploads",gson.toJson(request));state.addProperty("datasetId",created.get("datasetId").getAsString());Files.writeString(ledger,gson.toJson(state));}
|
||||
String fingerprint=hash((website+"\n"+hash(token.getBytes(StandardCharsets.UTF_8))+"\n"+gson.toJson(manifest)).getBytes(StandardCharsets.UTF_8));
|
||||
if(!state.has("hash")||!state.get("hash").getAsString().equals(fingerprint)){
|
||||
state=new JsonObject();state.addProperty("hash",fingerprint);JsonObject body=new JsonObject();body.add("manifest",manifest);progress.accept("Creating private draft");checkCancelled(cancelled);
|
||||
JsonObject created=api("/uploads",gson.toJson(body));state.addProperty("datasetId",created.get("datasetId").getAsString());save(ledger,state);
|
||||
}
|
||||
String datasetId=state.get("datasetId").getAsString();
|
||||
JsonObject request=new JsonObject();request.addProperty("datasetId",datasetId);JsonObject signed=api("/uploads/sign",gson.toJson(request));
|
||||
for(JsonElement elem:signed.getAsJsonArray("assets")){JsonObject asset=elem.getAsJsonObject();String name=asset.get("path").getAsString();Path file=directory.resolve(name).normalize();if(!file.startsWith(directory.normalize()))throw new IOException("Unsafe asset path");long length=Files.size(file);String signedToken=asset.get("token").getAsString();String endpoint=asset.get("endpoint").getAsString();String key="url:"+name;String uploadUrl=state.has(key)?state.get(key).getAsString():null;long offset=0;
|
||||
if(uploadUrl!=null){var head=client.send(HttpRequest.newBuilder(URI.create(uploadUrl)).timeout(Duration.ofSeconds(45)).header("Tus-Resumable","1.0.0").header("x-signature",signedToken).method("HEAD",HttpRequest.BodyPublishers.noBody()).build(),HttpResponse.BodyHandlers.discarding());if(head.statusCode()==200||head.statusCode()==204)offset=Long.parseLong(head.headers().firstValue("Upload-Offset").orElse("0"));else uploadUrl=null;}
|
||||
if(uploadUrl==null){String metadata="bucketName "+b64("minegeo-raw")+",objectName "+b64(asset.get("objectName").getAsString())+",contentType "+b64("application/octet-stream")+",cacheControl "+b64("3600");var created=client.send(HttpRequest.newBuilder(URI.create(endpoint)).timeout(Duration.ofSeconds(60)).header("Tus-Resumable","1.0.0").header("Upload-Length",Long.toString(length)).header("Upload-Metadata",metadata).header("x-signature",signedToken).POST(HttpRequest.BodyPublishers.noBody()).build(),HttpResponse.BodyHandlers.ofString());if(created.statusCode()!=201)throw new IOException("TUS create failed: "+created.statusCode()+" "+created.body());uploadUrl=URI.create(endpoint).resolve(created.headers().firstValue("Location").orElseThrow()).toString();state.addProperty(key,uploadUrl);Files.writeString(ledger,gson.toJson(state));}
|
||||
try(RandomAccessFile input=new RandomAccessFile(file.toFile(),"r")){while(offset<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());}}
|
||||
JsonObject existing=api("/datasets/"+datasetId,null);String stage=existing.get("state").getAsString();
|
||||
if(!stage.equals("uploading")){
|
||||
if(stage.equals("failed"))throw new IOException("The website could not process this export. Check its dashboard error; create a new export after fixing the cause.");
|
||||
progress.accept("Already uploaded — draft "+datasetId+" ("+stage+")");return;
|
||||
}
|
||||
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));}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: MineGeo
|
||||
version: 0.1.0
|
||||
version: 0.1.1
|
||||
main: dev.minegeo.plugin.MineGeoPlugin
|
||||
api-version: '1.21'
|
||||
description: Bounded terrain export for MineGeo panorama guessing
|
||||
|
||||
4
next-env.d.ts
vendored
4
next-env.d.ts
vendored
@@ -1,7 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/dev/types/root-params.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
93
public/fonts/OFL-VT323.txt
Normal file
93
public/fonts/OFL-VT323.txt
Normal 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.
|
||||
BIN
public/fonts/VT323-Regular.ttf
Normal file
BIN
public/fonts/VT323-Regular.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,7 +4,8 @@ import { mkdir, copyFile } from "node:fs/promises";
|
||||
const windows = process.platform === "win32";
|
||||
const command = windows ? "gradlew.bat" : "sh";
|
||||
const targets = process.argv.slice(2);
|
||||
if (!targets.length) targets.push(":plugin:build", ":fabric:build");
|
||||
if (!targets.some((target) => !target.startsWith("-")))
|
||||
targets.unshift(":plugin:build", ":fabric:build");
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, windows ? targets : ["./gradlew", ...targets], {
|
||||
cwd: "minecraft",
|
||||
|
||||
@@ -29,5 +29,5 @@ const configured = Boolean(
|
||||
console.log(
|
||||
configured
|
||||
? "Supabase configuration present. Apply the checked-in migration before starting."
|
||||
: "Supabase is not configured. Local guest sandbox is available; hosted uploads/moderation remain disabled.",
|
||||
: "Supabase is not configured. Fill the URL, publishable key and server secret in .env.local before starting MineGeo.",
|
||||
);
|
||||
|
||||
210
scripts/test-api.ts
Normal file
210
scripts/test-api.ts
Normal 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
232
scripts/test-plugin.ts
Normal 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);
|
||||
}
|
||||
@@ -56,6 +56,23 @@ export const datasetSchema = z
|
||||
.max(4096),
|
||||
})
|
||||
.superRefine((d, ctx) => {
|
||||
const paths = new Set(d.assets.map((asset) => asset.path));
|
||||
const requiredPaths =
|
||||
d.source === "plugin"
|
||||
? ["terrain.json.gz"]
|
||||
: [
|
||||
"map.png",
|
||||
...d.viewpoints.flatMap((point) =>
|
||||
faces.map((face) => `${point.id}/${face}.png`),
|
||||
),
|
||||
];
|
||||
for (const path of requiredPaths) {
|
||||
if (!paths.has(path))
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `Missing required export asset: ${path}`,
|
||||
});
|
||||
}
|
||||
if (new Set(d.viewpoints.map((p) => p.id)).size !== d.viewpoints.length)
|
||||
ctx.addIssue({ code: "custom", message: "Viewpoint IDs must be unique" });
|
||||
if (new Set(d.assets.map((a) => a.path)).size !== d.assets.length)
|
||||
|
||||
9
tests/java/UploadSmoke.java
Normal file
9
tests/java/UploadSmoke.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,29 @@ test("dataset requires five unique in-bounds viewpoints and safe paths", () => {
|
||||
assets: [{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 100 }],
|
||||
};
|
||||
assert.equal(datasetSchema.safeParse(base).success, true);
|
||||
assert.equal(datasetSchema.safeParse({ ...base, assets: [] }).success, false);
|
||||
assert.equal(
|
||||
datasetSchema.safeParse({ ...base, source: "fabric" }).success,
|
||||
false,
|
||||
);
|
||||
const capture = {
|
||||
...base,
|
||||
source: "fabric",
|
||||
assets: [
|
||||
"map.png",
|
||||
...base.viewpoints.flatMap((point) =>
|
||||
["px", "nx", "py", "ny", "pz", "nz"].map(
|
||||
(face) => `${point.id}/${face}.png`,
|
||||
),
|
||||
),
|
||||
].map((path) => ({ path, sha256: "a".repeat(64), bytes: 100 })),
|
||||
};
|
||||
assert.equal(datasetSchema.safeParse(capture).success, true);
|
||||
assert.equal(
|
||||
datasetSchema.safeParse({ ...capture, assets: capture.assets.slice(1) })
|
||||
.success,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
datasetSchema.safeParse({ ...base, viewpoints: base.viewpoints.slice(1) })
|
||||
.success,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
import { mkdir, writeFile, readFile, readdir, rm } from "node:fs/promises";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
@@ -6,6 +7,7 @@ import sharp from "sharp";
|
||||
import { datasetSchema, faces, type TerrainSnapshot } from "../shared/protocol";
|
||||
import { renderSnapshot } from "../renderer/render.mjs";
|
||||
import type { Database } from "../shared/database";
|
||||
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
||||
const client = createClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SECRET_KEY!,
|
||||
|
||||
Reference in New Issue
Block a user