fixes
All checks were successful
Deploy to Docker / deploy (push) Successful in 1m23s

This commit is contained in:
2026-03-17 01:58:20 +00:00
parent a30671c44c
commit 7218f06934
3 changed files with 226 additions and 32 deletions

7
.mcp.json Normal file
View File

@@ -0,0 +1,7 @@
{
"mcpServers": {
"minecraft": {
"url": "http://localhost:3002/mcp"
}
}
}

View File

@@ -190,43 +190,75 @@ export async function downloadSchematic(projectUrl, timeoutMs = 60000) {
await page.goto(projectUrl, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
await waitForCloudflare(page, timeoutMs);
// Step 2: Navigate to download confirmation page
const downloadPageUrl = projectUrl.replace(/\/?$/, '/download/schematic/');
// Step 2: Navigate to download page (worldmap has the Schemagic embed with the real URL)
const downloadPageUrl = projectUrl.replace(/\/?$/, '/download/worldmap/');
log(TAG, `Navigating to download page: ${downloadPageUrl}`);
await page.goto(downloadPageUrl, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
await waitForCloudflare(page, timeoutMs);
// Step 3: Try to extract the static file URL from <a download> element
let staticUrl = null;
try {
staticUrl = await page.getAttribute('a[download]', 'href', { timeout: 5000 });
if (staticUrl) {
log(TAG, `Found static download URL: ${staticUrl}`);
}
} catch {
log(TAG, 'No <a download> element found, will try countdown fallback');
// Step 3: Extract schematic URL from Schemagic.load() JS call or <a download> element
let schematicUrl = null;
// 3a: Look for Schemagic.load({ schematic: "https://...resource_media/schematic/..." })
const html = await page.content();
const schemagicMatch = html.match(/Schemagic\.load\s*\(\s*\{[^}]*schematic:\s*"([^"]+)"/);
if (schemagicMatch) {
schematicUrl = schemagicMatch[1];
log(TAG, `Found Schemagic schematic URL: ${schematicUrl}`);
}
// Also try broader selectors if the first didn't work
if (!staticUrl) {
// 3b: Look for direct resource_media URLs in the page (signed S3 links)
if (!schematicUrl) {
const resourceMatch = html.match(/https?:\/\/[^"'\s]*static\.planetminecraft\.com\/files\/resource_media\/schematic[^"'\s]*/);
if (resourceMatch) {
schematicUrl = resourceMatch[0];
log(TAG, `Found resource_media schematic URL: ${schematicUrl}`);
}
}
// 3c: Try <a download> element
if (!schematicUrl) {
try {
staticUrl = await page.getAttribute('a[href*="static.planetminecraft.com"]', 'href', { timeout: 3000 });
if (staticUrl) {
log(TAG, `Found static PMC URL: ${staticUrl}`);
schematicUrl = await page.getAttribute('a[download]', 'href', { timeout: 5000 });
if (schematicUrl) {
log(TAG, `Found <a download> URL: ${schematicUrl}`);
}
} catch {
// Will use fallback
log(TAG, 'No <a download> element found');
}
}
// 3d: Also try /download/schematic/ page as fallback
if (!schematicUrl) {
const schematicPageUrl = projectUrl.replace(/\/?$/, '/download/schematic/');
log(TAG, `Trying schematic download page: ${schematicPageUrl}`);
await page.goto(schematicPageUrl, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
await waitForCloudflare(page, timeoutMs);
const schematicHtml = await page.content();
const schematicPageMatch = schematicHtml.match(/Schemagic\.load\s*\(\s*\{[^}]*schematic:\s*"([^"]+)"/);
if (schematicPageMatch) {
schematicUrl = schematicPageMatch[1];
log(TAG, `Found Schemagic URL on schematic page: ${schematicUrl}`);
}
if (!schematicUrl) {
const resourceMatch2 = schematicHtml.match(/https?:\/\/[^"'\s]*static\.planetminecraft\.com\/files\/resource_media\/schematic[^"'\s]*/);
if (resourceMatch2) {
schematicUrl = resourceMatch2[0];
log(TAG, `Found resource_media URL on schematic page: ${schematicUrl}`);
}
}
}
let downloadBuffer;
if (staticUrl) {
// Step 4a: Download via navigating to the static URL
log(TAG, 'Downloading via static URL...');
if (schematicUrl) {
// Step 4a: Download via navigating to the S3 URL (triggers download event)
log(TAG, `Downloading schematic from: ${schematicUrl.slice(0, 100)}...`);
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: timeoutMs }),
page.evaluate((url) => { window.location.href = url; }, staticUrl),
page.evaluate((url) => { window.location.href = url; }, schematicUrl),
]);
const path = await download.path();
if (!path) throw new Error('Download failed — no file path returned');
@@ -234,10 +266,9 @@ export async function downloadSchematic(projectUrl, timeoutMs = 60000) {
downloadBuffer = readFileSync(path);
} else {
// Step 4b: Fallback — wait for countdown and click download button
log(TAG, 'Waiting for countdown timer to complete...');
// Wait for the download button/link to become active (countdown is typically 5s)
log(TAG, 'No schematic URL found, trying countdown fallback...');
try {
await page.waitForSelector('a.download-action:not([disabled]), a[href*="static.planetminecraft.com"], .confirm-download a', {
await page.waitForSelector('a.download-action:not([disabled]), .confirm-download a', {
timeout: 15000,
state: 'visible',
});
@@ -247,7 +278,7 @@ export async function downloadSchematic(projectUrl, timeoutMs = 60000) {
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: timeoutMs }),
page.click('a.download-action, a[href*="static.planetminecraft.com"], .confirm-download a'),
page.click('a.download-action, .confirm-download a'),
]);
const path = await download.path();
if (!path) throw new Error('Download failed — no file path returned');

View File

@@ -207,13 +207,173 @@ function extractFromZipIfNeeded(buffer) {
}
/**
* Parse a raw .schematic/.schem buffer into our blueprint format.
* Try to parse a buffer as Litematica format (.litematic).
* Returns null if not a litematic file.
* @param {Buffer} buffer
* @returns {Promise<object|null>} { name, size, voxels } or null
*/
async function tryParseLitematic(buffer) {
let nbt;
try {
const nbtMod = await import('prismarine-nbt');
nbt = nbtMod.default || nbtMod;
} catch {
return null;
}
let data;
try {
const { parsed } = await nbt.parse(buffer);
data = nbt.simplify(parsed);
} catch {
return null;
}
// Check if this is a Litematica file (has Regions and Version keys)
if (!data.Regions || !data.Version) return null;
log(TAG, `Detected Litematica format v${data.Version}`);
const regionNames = Object.keys(data.Regions);
if (regionNames.length === 0) throw new Error('Litematica file has no regions');
const voxels = [];
let totalSizeX = 0, totalSizeY = 0, totalSizeZ = 0;
for (const regionName of regionNames) {
const region = data.Regions[regionName];
const palette = region.BlockStatePalette;
const blockStates = region.BlockStates;
const size = region.Size;
const pos = region.Position || { x: 0, y: 0, z: 0 };
if (!palette || !blockStates || !size) {
log(TAG, `Skipping region "${regionName}" — missing data`);
continue;
}
// Litematica sizes can be negative (indicates direction), use absolute values
const sizeX = Math.abs(size.x);
const sizeY = Math.abs(size.y);
const sizeZ = Math.abs(size.z);
totalSizeX = Math.max(totalSizeX, pos.x + sizeX);
totalSizeY = Math.max(totalSizeY, pos.y + sizeY);
totalSizeZ = Math.max(totalSizeZ, pos.z + sizeZ);
const bitsPerBlock = Math.max(Math.ceil(Math.log2(palette.length)), 2);
const mask = (1n << BigInt(bitsPerBlock)) - 1n;
const volume = sizeX * sizeY * sizeZ;
log(TAG, `Region "${regionName}": ${sizeX}x${sizeY}x${sizeZ}, ${palette.length} palette entries, ${bitsPerBlock} bits/block`);
for (let i = 0; i < volume; i++) {
// Litematica v7: bit-spanning across longs
const bitOffset = i * bitsPerBlock;
const longIndex = Math.floor(bitOffset / 64);
const bitInLong = bitOffset % 64;
if (longIndex >= blockStates.length) break;
let value = BigInt(blockStates[longIndex]);
// Handle negative BigInts (unsigned interpretation)
if (value < 0n) value = value + (1n << 64n);
let paletteIndex = (value >> BigInt(bitInLong)) & mask;
// If the value spans across two longs
if (bitInLong + bitsPerBlock > 64 && longIndex + 1 < blockStates.length) {
let nextValue = BigInt(blockStates[longIndex + 1]);
if (nextValue < 0n) nextValue = nextValue + (1n << 64n);
const bitsFromNext = bitInLong + bitsPerBlock - 64;
const nextMask = (1n << BigInt(bitsFromNext)) - 1n;
paletteIndex |= (nextValue & nextMask) << BigInt(64 - bitInLong);
}
const idx = Number(paletteIndex);
if (idx < 0 || idx >= palette.length) continue;
const block = palette[idx];
const blockName = (block.Name || block.name || '').replace('minecraft:', '');
if (!blockName || blockName === 'air' || blockName === 'cave_air' || blockName === 'void_air') {
continue;
}
// Litematica index order: x + z * sizeX + y * sizeX * sizeZ
const x = i % sizeX;
const z = Math.floor(i / sizeX) % sizeZ;
const y = Math.floor(i / (sizeX * sizeZ));
// Build block name with properties for state resolution
let matId = blockName;
if (block.Properties) {
const props = Object.entries(block.Properties)
.map(([k, v]) => `${k}=${v}`)
.join(',');
if (props) matId = `${blockName}[${props}]`;
}
voxels.push({
x: pos.x + x,
y: pos.y + y,
z: pos.z + z,
matId: blockName,
matName: matId,
hex: null,
});
}
}
// Use enclosing size from metadata if available
const enclosing = data.Metadata?.EnclosingSize;
const liteName = data.Metadata?.Name || regionNames[0];
return {
name: liteName,
size: {
x: enclosing?.x || totalSizeX,
y: enclosing?.y || totalSizeY,
z: enclosing?.z || totalSizeZ,
},
voxels,
};
}
/**
* Parse a raw .schematic/.schem/.litematic buffer into our blueprint format.
* @param {Buffer} buffer
* @param {string} id
* @param {string} url
* @returns {Promise<object>}
*/
async function parseSchematicBuffer(buffer, id, url) {
// Get name from cached metadata
const meta = cache.get('meta', id);
const metaName = meta?.name || `Schematic ${id}`;
// Try Litematica format first (since prismarine-schematic doesn't support it)
const litematic = await tryParseLitematic(buffer);
if (litematic) {
const dimensions = {
width: litematic.size.x,
height: litematic.size.y,
depth: litematic.size.z,
};
const name = litematic.name || metaName;
const blueprint = {
name,
url,
voxels: litematic.voxels,
materials: [],
dimensions,
totalBlocks: litematic.voxels.length,
origin: { x: 0, y: 0, z: 0 },
};
log(TAG, `Parsed litematic "${name}": ${litematic.voxels.length} blocks, ${dimensions.width}x${dimensions.height}x${dimensions.depth}`);
return blueprint;
}
// Fall back to prismarine-schematic for .schematic and .schem formats
let Schematic, Vec3;
try {
const mod = await import('prismarine-schematic');
@@ -231,10 +391,6 @@ async function parseSchematicBuffer(buffer, id, url) {
throw new Error(`Failed to parse schematic file: ${err.message}. The file may be in an unsupported format.`);
}
// Get name from cached metadata
const meta = cache.get('meta', id);
const name = meta?.name || `Schematic ${id}`;
// Extract voxels by iterating all blocks
const voxels = [];
const start = schematic.start();
@@ -270,7 +426,7 @@ async function parseSchematicBuffer(buffer, id, url) {
};
const blueprint = {
name,
name: metaName,
url,
voxels,
materials: [],
@@ -279,7 +435,7 @@ async function parseSchematicBuffer(buffer, id, url) {
origin: { x: 0, y: 0, z: 0 },
};
log(TAG, `Parsed "${name}": ${voxels.length} blocks, ${dimensions.width}x${dimensions.height}x${dimensions.depth}`);
log(TAG, `Parsed "${metaName}": ${voxels.length} blocks, ${dimensions.width}x${dimensions.height}x${dimensions.depth}`);
return blueprint;
}