diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..07dde52 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "minecraft": { + "url": "http://localhost:3002/mcp" + } + } +} diff --git a/src/schematics-browser.js b/src/schematics-browser.js index e30d365..6cac02e 100644 --- a/src/schematics-browser.js +++ b/src/schematics-browser.js @@ -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 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 element found, will try countdown fallback'); + // Step 3: Extract schematic URL from Schemagic.load() JS call or 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 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 URL: ${schematicUrl}`); } } catch { - // Will use fallback + log(TAG, 'No 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'); diff --git a/src/schematics.js b/src/schematics.js index b871569..24d42f8 100644 --- a/src/schematics.js +++ b/src/schematics.js @@ -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} { 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} */ 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; }