This commit is contained in:
7
.mcp.json
Normal file
7
.mcp.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"minecraft": {
|
||||
"url": "http://localhost:3002/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user