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 page.goto(projectUrl, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
|
||||||
await waitForCloudflare(page, timeoutMs);
|
await waitForCloudflare(page, timeoutMs);
|
||||||
|
|
||||||
// Step 2: Navigate to download confirmation page
|
// Step 2: Navigate to download page (worldmap has the Schemagic embed with the real URL)
|
||||||
const downloadPageUrl = projectUrl.replace(/\/?$/, '/download/schematic/');
|
const downloadPageUrl = projectUrl.replace(/\/?$/, '/download/worldmap/');
|
||||||
log(TAG, `Navigating to download page: ${downloadPageUrl}`);
|
log(TAG, `Navigating to download page: ${downloadPageUrl}`);
|
||||||
await page.goto(downloadPageUrl, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
|
await page.goto(downloadPageUrl, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
|
||||||
await waitForCloudflare(page, timeoutMs);
|
await waitForCloudflare(page, timeoutMs);
|
||||||
|
|
||||||
// Step 3: Try to extract the static file URL from <a download> element
|
// Step 3: Extract schematic URL from Schemagic.load() JS call or <a download> element
|
||||||
let staticUrl = null;
|
let schematicUrl = null;
|
||||||
try {
|
|
||||||
staticUrl = await page.getAttribute('a[download]', 'href', { timeout: 5000 });
|
// 3a: Look for Schemagic.load({ schematic: "https://...resource_media/schematic/..." })
|
||||||
if (staticUrl) {
|
const html = await page.content();
|
||||||
log(TAG, `Found static download URL: ${staticUrl}`);
|
const schemagicMatch = html.match(/Schemagic\.load\s*\(\s*\{[^}]*schematic:\s*"([^"]+)"/);
|
||||||
}
|
if (schemagicMatch) {
|
||||||
} catch {
|
schematicUrl = schemagicMatch[1];
|
||||||
log(TAG, 'No <a download> element found, will try countdown fallback');
|
log(TAG, `Found Schemagic schematic URL: ${schematicUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also try broader selectors if the first didn't work
|
// 3b: Look for direct resource_media URLs in the page (signed S3 links)
|
||||||
if (!staticUrl) {
|
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 {
|
try {
|
||||||
staticUrl = await page.getAttribute('a[href*="static.planetminecraft.com"]', 'href', { timeout: 3000 });
|
schematicUrl = await page.getAttribute('a[download]', 'href', { timeout: 5000 });
|
||||||
if (staticUrl) {
|
if (schematicUrl) {
|
||||||
log(TAG, `Found static PMC URL: ${staticUrl}`);
|
log(TAG, `Found <a download> URL: ${schematicUrl}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} 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;
|
let downloadBuffer;
|
||||||
|
|
||||||
if (staticUrl) {
|
if (schematicUrl) {
|
||||||
// Step 4a: Download via navigating to the static URL
|
// Step 4a: Download via navigating to the S3 URL (triggers download event)
|
||||||
log(TAG, 'Downloading via static URL...');
|
log(TAG, `Downloading schematic from: ${schematicUrl.slice(0, 100)}...`);
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent('download', { timeout: timeoutMs }),
|
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();
|
const path = await download.path();
|
||||||
if (!path) throw new Error('Download failed — no file path returned');
|
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);
|
downloadBuffer = readFileSync(path);
|
||||||
} else {
|
} else {
|
||||||
// Step 4b: Fallback — wait for countdown and click download button
|
// Step 4b: Fallback — wait for countdown and click download button
|
||||||
log(TAG, 'Waiting for countdown timer to complete...');
|
log(TAG, 'No schematic URL found, trying countdown fallback...');
|
||||||
// Wait for the download button/link to become active (countdown is typically 5s)
|
|
||||||
try {
|
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,
|
timeout: 15000,
|
||||||
state: 'visible',
|
state: 'visible',
|
||||||
});
|
});
|
||||||
@@ -247,7 +278,7 @@ export async function downloadSchematic(projectUrl, timeoutMs = 60000) {
|
|||||||
|
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent('download', { timeout: timeoutMs }),
|
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();
|
const path = await download.path();
|
||||||
if (!path) throw new Error('Download failed — no file path returned');
|
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 {Buffer} buffer
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {string} url
|
* @param {string} url
|
||||||
* @returns {Promise<object>}
|
* @returns {Promise<object>}
|
||||||
*/
|
*/
|
||||||
async function parseSchematicBuffer(buffer, id, url) {
|
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;
|
let Schematic, Vec3;
|
||||||
try {
|
try {
|
||||||
const mod = await import('prismarine-schematic');
|
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.`);
|
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
|
// Extract voxels by iterating all blocks
|
||||||
const voxels = [];
|
const voxels = [];
|
||||||
const start = schematic.start();
|
const start = schematic.start();
|
||||||
@@ -270,7 +426,7 @@ async function parseSchematicBuffer(buffer, id, url) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const blueprint = {
|
const blueprint = {
|
||||||
name,
|
name: metaName,
|
||||||
url,
|
url,
|
||||||
voxels,
|
voxels,
|
||||||
materials: [],
|
materials: [],
|
||||||
@@ -279,7 +435,7 @@ async function parseSchematicBuffer(buffer, id, url) {
|
|||||||
origin: { x: 0, y: 0, z: 0 },
|
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;
|
return blueprint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user