| import express from "express"; |
| import { createServer } from "http"; |
| import { Server } from "socket.io"; |
| import { createServer as createViteServer } from "vite"; |
| import path from "path"; |
| import mineflayer from "mineflayer"; |
| import pkg from "mineflayer-pathfinder"; |
| const { pathfinder, Movements, goals } = pkg; |
| import { GoogleGenAI, Type } from "@google/genai"; |
|
|
| const app = express(); |
| const httpServer = createServer(app); |
| const io = new Server(httpServer, { |
| cors: { |
| origin: "*", |
| }, |
| }); |
|
|
| const PORT = Number(process.env.PORT) || 7860; |
|
|
| let bot: mineflayer.Bot | null = null; |
| let ai: GoogleGenAI | null = null; |
| let selectedModel: string = "gemini-3-flash-preview"; |
| let apiType: string = "google"; |
| let apiBaseUrl: string = ""; |
| let customModel: string = ""; |
| let maxBlocks: number = 10000; |
| let globalApiKey: string = ""; |
| let currentEdition: string = "java"; |
| let chatHistory: { role: string, content: string }[] = []; |
|
|
| |
| const log = (message: string, type: "info" | "success" | "error" | "chat" = "info") => { |
| io.emit("bot-log", { message, type, timestamp: new Date().toISOString() }); |
| console.log(`[${type.toUpperCase()}] ${message}`); |
| }; |
|
|
| async function startServer() { |
| |
| app.get("/api/status", (req, res) => { |
| res.json({ connected: !!bot, username: bot?.username }); |
| }); |
|
|
| |
| io.on("connection", (socket) => { |
| log("Frontend connected to control panel"); |
|
|
| socket.on("connect-bot", (config) => { |
| const { host, port, version, username, apiKey, model } = config; |
|
|
| if (bot) { |
| bot.quit(); |
| log("Existing bot disconnected"); |
| } |
|
|
| try { |
| globalApiKey = apiKey; |
| apiType = config.apiType || "google"; |
| apiBaseUrl = config.apiBaseUrl || ""; |
| customModel = config.customModel || ""; |
| maxBlocks = parseInt(config.maxBlocks) || 10000; |
| selectedModel = model || "gemini-3-flash-preview"; |
| currentEdition = config.edition || "java"; |
| const authType = config.auth || "offline"; |
|
|
| if (apiType === "google") { |
| ai = new GoogleGenAI({ apiKey }); |
| } else { |
| ai = null; |
| } |
| |
| bot = mineflayer.createBot({ |
| host, |
| port: parseInt(port) || 25565, |
| username, |
| version: version || false, |
| auth: authType as 'offline' | 'microsoft', |
| onMsaCode: (data) => { |
| io.emit("bot-msa-code", { code: data.user_code, uri: data.verification_uri }); |
| log(`Waiting for Microsoft login. Go to ${data.verification_uri} and enter code: ${data.user_code}`, "info"); |
| } |
| }); |
|
|
| bot.loadPlugin(pathfinder); |
|
|
| bot.on("spawn", () => { |
| log(`Bot ${bot?.username} spawned on ${host}:${port}`, "success"); |
| io.emit("bot-status", { connected: true, username: bot?.username }); |
| }); |
|
|
| bot.on("chat", async (username, message) => { |
| if (username === bot?.username) return; |
| log(`${username}: ${message}`, "chat"); |
|
|
| |
| if (message.startsWith("!build ") || message.startsWith("!б ")) { |
| const prompt = message.substring(message.indexOf(" ") + 1); |
| await handleBuildRequest(prompt, username, false); |
| } else if (message.startsWith("!next ") || message.startsWith("!н ")) { |
| const prompt = message.substring(message.indexOf(" ") + 1); |
| await handleBuildRequest(prompt, username, true); |
| } |
| }); |
|
|
| bot.on("error", (err) => log(`Bot error: ${err.message}`, "error")); |
| bot.on("kicked", (reason) => { |
| log(`Bot kicked: ${reason}`, "error"); |
| bot = null; |
| io.emit("bot-status", { connected: false }); |
| }); |
|
|
| } catch (error: any) { |
| log(`Failed to initialize: ${error.message}`, "error"); |
| } |
| }); |
|
|
| socket.on("disconnect-bot", () => { |
| if (bot) { |
| bot.quit(); |
| bot = null; |
| log("Bot disconnected manually"); |
| io.emit("bot-status", { connected: false }); |
| } |
| }); |
| }); |
|
|
| async function handleBuildRequest(prompt: string, requestingUser: string, isNext: boolean) { |
| if (!bot) return; |
|
|
| log(`AI is thinking about: "${prompt}"...`); |
| bot.chat(`Request received from ${requestingUser}, beginning generation...`); |
|
|
| try { |
| function getEntityLookingAt(userEntity: any, entities: any, botEntity: any) { |
| if (!userEntity || !userEntity.position) return null; |
| const pos = { x: userEntity.position.x, y: userEntity.position.y + 1.62, z: userEntity.position.z }; |
| const yaw = userEntity.yaw; |
| const pitch = userEntity.pitch; |
| const dx = -Math.sin(yaw) * Math.cos(pitch); |
| const dy = -Math.sin(pitch); |
| const dz = -Math.cos(yaw) * Math.cos(pitch); |
|
|
| let bestEntity = null; |
| let bestDistance = Infinity; |
|
|
| for (const id in entities) { |
| const entity = entities[id]; |
| if (entity === userEntity || entity === botEntity) continue; |
| if (!entity.position || !['mob', 'player', 'hostile', 'other'].includes(entity.type)) continue; |
|
|
| const ex = entity.position.x - pos.x; |
| const ey = (entity.position.y + (entity.height || 1) / 2) - pos.y; |
| const ez = entity.position.z - pos.z; |
| const dist = Math.sqrt(ex*ex + ey*ey + ez*ez); |
| |
| if (dist > 30) continue; |
|
|
| const nx = ex / dist; |
| const ny = ey / dist; |
| const nz = ez / dist; |
|
|
| const dot = dx*nx + dy*ny + dz*nz; |
| if (dot > 0.95 && dist < bestDistance) { |
| bestDistance = dist; |
| bestEntity = entity; |
| } |
| } |
| return bestEntity; |
| } |
|
|
| const userEntity = bot.players[requestingUser]?.entity; |
| const lookingAtEntity = getEntityLookingAt(userEntity, bot.entities, bot.entity); |
| |
| let lookingAtText = "Nothing"; |
| if (lookingAtEntity) { |
| const name = lookingAtEntity.username || lookingAtEntity.name || lookingAtEntity.displayName || "unknown"; |
| lookingAtText = `${name} (ID: ${lookingAtEntity.id})`; |
| } |
|
|
| const nearbyEntities = Object.values(bot.entities) |
| .filter(e => e !== bot?.entity && e.position && bot!.entity.position.distanceTo(e.position) < 30) |
| .filter(e => e.type === 'mob' || e.type === 'player' || e.type === 'hostile' || e.type === 'other') |
| .map(e => `${e.username || e.name} (ID: ${e.id}, dist: ${Math.round(bot!.entity.position.distanceTo(e.position))}m)`) |
| .slice(0, 15) |
| .join(", ") || "No entities nearby"; |
|
|
| const syntaxRules = currentEdition === "bedrock" |
| ? `CRITICAL BEDROCK COMMAND SYNTAX: |
| - Bedrock DOES NOT support NBT tags in commands. |
| - To give items: give ${requestingUser} diamond_sword 1 |
| - To summon with name: summon armor_stand "Palace" ~2 ~ ~` |
| : `CRITICAL 1.21.1 JAVA COMMAND SYNTAX: |
| - Items with custom names/enchantments: give ${requestingUser} cobblestone[custom_name=[{"text":"Name","italic":false}],enchantments={channeling:1}] |
| - Summoning entities with visible names: summon armor_stand ~2 ~ ~ {CustomName:'{"text":"Palace"}',CustomNameVisible:1b,ArmorItems:[{},{},{},{id:"minecraft:shield",count:1}]}`; |
|
|
| const equipRules = currentEdition === "bedrock" |
| ? `3. EQUIPPING ARMOR/WEAPONS (BEDROCK): Use /replaceitem. |
| ["cmd", "replaceitem entity @s slot.armor.head 0 netherite_helmet"] |
| ["cmd", "replaceitem entity @s slot.armor.chest 0 netherite_chestplate"] |
| ["cmd", "replaceitem entity @s slot.armor.legs 0 netherite_leggings"] |
| ["cmd", "replaceitem entity @s slot.armor.feet 0 netherite_boots"] |
| ["cmd", "replaceitem entity @s slot.weapon.mainhand 0 netherite_sword"] |
| ["cmd", "replaceitem entity @s slot.weapon.offhand 0 shield"]` |
| : `3. EQUIPPING ARMOR/WEAPONS (JAVA): To actually wear armor or hold weapons, you MUST use these commands (do not just use /give): |
| ["cmd", "item replace entity @s armor.head with netherite_helmet"] |
| ["cmd", "item replace entity @s armor.chest with netherite_chestplate"] |
| ["cmd", "item replace entity @s armor.legs with netherite_leggings"] |
| ["cmd", "item replace entity @s armor.feet with netherite_boots"] |
| ["cmd", "item replace entity @s weapon.mainhand with netherite_sword"] |
| ["cmd", "item replace entity @s weapon.offhand with shield"]`; |
|
|
| const systemPrompt = `You are a Minecraft architect, admin, and player. The user "${requestingUser}" wants: "${prompt}". |
| You can build structures, execute commands, move around, or interact with players. |
| Coordinates (x, y, z) for blocks and movement are relative to the bot's starting position (0,0,0). |
| For commands (like /summon), use relative coordinates (~ ~ ~) which are relative to the bot! |
| Use standard Minecraft ${currentEdition === 'bedrock' ? 'Bedrock' : 'Java 1.21.1'} block names and command syntax. |
| |
| ENVIRONMENT CONTEXT (What you currently see): |
| - Nearby entities: ${nearbyEntities} |
| - User "${requestingUser}" is currently looking at: ${lookingAtText} |
| - Your position: ${Math.round(bot.entity.position.x)}, ${Math.round(bot.entity.position.y)}, ${Math.round(bot.entity.position.z)} |
| |
| ${syntaxRules} |
| |
| IMPORTANT RULES: |
| 1. When BUILDING structures, ALWAYS start by teleporting or moving away from the build area so you don't get stuck. |
| 2. When ATTACKING or FOLLOWING an entity, NEVER use the "move" action to go to their coordinates. ONLY use the "attack" or "follow" action directly. The bot will track them automatically. |
| ${equipRules} |
| 4. PVP DUEL PROTOCOL: If the user asks for a fight, duel, or "PvP mode": |
| - Build a fighting ring/arena around your current position (floor at y=-1, walls around). |
| - Equip EQUAL armor and weapons to BOTH yourself AND the user. |
| - Teleport the user and yourself inside the ring on opposite sides (e.g., ["cmd", "tp @s ~4 ~ ~"] and ["cmd", "tp ${requestingUser} ~-4 ~ ~"]). |
| - Set both gamemodes to survival (["cmd", "gamemode survival @s"] and ["cmd", "gamemode survival ${requestingUser}"]). |
| - Finally, use the ["attack", "${requestingUser}"] action to start the epic battle! |
| |
| You can build large structures up to ${maxBlocks} blocks. |
| To save tokens, output a JSON object containing an "actions" array. |
| Action type 1 (Place Block): [x, y, z, "block_name"] |
| Action type 2 (Execute Command): ["cmd", "command_string"] |
| Action type 3 (Walk/Parkour): ["move", x, y, z] |
| Action type 4 (Teleport): ["tp", x, y, z] |
| Action type 5 (Attack Entity): ["attack", "player_name_or_entity_id"] |
| Action type 6 (Follow Entity): ["follow", "player_name_or_entity_id"] |
| |
| Example: |
| { |
| "actions": [ |
| ["cmd", "item replace entity @s weapon.mainhand with diamond_sword"], |
| ["attack", "PlayerName"] |
| ] |
| } |
| Output ONLY the JSON object.`; |
|
|
| if (!isNext) { |
| chatHistory = []; |
| } |
|
|
| let userMessage = prompt; |
| if (isNext) { |
| userMessage = `CONTINUE OR MODIFY THE PREVIOUS BUILD. User request: "${prompt}". Output the FULL JSON array of actions to execute next (e.g. replacing blocks, adding new ones, etc). Keep in mind the coordinates of the previous blocks.`; |
| } |
|
|
| chatHistory.push({ role: "user", content: userMessage }); |
|
|
| let responseText = ""; |
|
|
| if (apiType === "openai") { |
| const url = apiBaseUrl.replace(/\/+$/, '') + '/chat/completions'; |
| const messages = [ |
| { role: "system", content: systemPrompt }, |
| ...chatHistory |
| ]; |
|
|
| const res = await fetch(url, { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| "Authorization": `Bearer ${globalApiKey}` |
| }, |
| body: JSON.stringify({ |
| model: customModel || "gpt-3.5-turbo", |
| messages: messages |
| }) |
| }); |
| |
| if (!res.ok) { |
| const errText = await res.text(); |
| throw new Error(`Custom API Error: ${res.status} ${errText}`); |
| } |
| |
| const data = await res.json(); |
| responseText = data.choices[0].message.content; |
| } else { |
| if (!ai) throw new Error("Google GenAI not initialized"); |
| |
| const contents = chatHistory.map(msg => ({ |
| role: msg.role === "assistant" ? "model" : "user", |
| parts: [{ text: msg.content }] |
| })); |
|
|
| const response = await ai.models.generateContent({ |
| model: selectedModel, |
| contents: contents, |
| config: { |
| systemInstruction: systemPrompt, |
| responseMimeType: "application/json", |
| } |
| }); |
| responseText = response.text; |
| } |
|
|
| chatHistory.push({ role: "assistant", content: responseText }); |
|
|
| |
| responseText = responseText.replace(/```json/g, '').replace(/```/g, '').trim(); |
| |
| |
| responseText = responseText.replace(/[\n\r\t]/g, ' ').replace(/[\x00-\x1F\x7F-\x9F]/g, ''); |
| |
| const parsed = JSON.parse(responseText); |
| const actions = parsed.actions; |
| |
| if (!Array.isArray(actions)) throw new Error("Invalid AI response format: missing 'actions' array"); |
|
|
| log(`AI generated ${actions.length} actions. Muting command feedback and starting execution...`, "success"); |
|
|
| |
| bot.chat('/gamerule sendCommandFeedback false'); |
| bot.chat('/gamerule logAdminCommands false'); |
|
|
| const startPos = bot.entity.position.clone(); |
|
|
| for (const action of actions) { |
| if (!bot) break; |
| if (!Array.isArray(action)) continue; |
| |
| try { |
| |
| await new Promise(r => setTimeout(r, 100)); |
| |
| if (action[0] === "cmd" && typeof action[1] === "string") { |
| let cmd = action[1]; |
| if (cmd.startsWith("/")) cmd = cmd.substring(1); |
| log(`Executing command: /${cmd}`, "info"); |
| bot.chat(`/${cmd}`); |
| } else if (action[0] === "move") { |
| const dx = Number(action[1]) || 0; |
| const dy = Number(action[2]) || 0; |
| const dz = Number(action[3]) || 0; |
| const targetPos = startPos.offset(dx, dy, dz); |
| log(`Walking to ~${dx} ~${dy} ~${dz}...`, "info"); |
| const defaultMove = new Movements(bot); |
| bot.pathfinder.setMovements(defaultMove); |
| try { |
| await bot.pathfinder.goto(new goals.GoalBlock(targetPos.x, targetPos.y, targetPos.z)); |
| } catch (e) { |
| log(`Pathfinding failed or interrupted`, "error"); |
| } |
| } else if (action[0] === "tp") { |
| const dx = Number(action[1]) || 0; |
| const dy = Number(action[2]) || 0; |
| const dz = Number(action[3]) || 0; |
| log(`Teleporting to ~${dx} ~${dy} ~${dz}`, "info"); |
| bot.chat(`/tp @s ~${dx} ~${dy} ~${dz}`); |
| await new Promise(r => setTimeout(r, 500)); |
| } else if (action[0] === "attack") { |
| const targetIdentifier = action[1]; |
| let target = bot.players[targetIdentifier]?.entity || bot.entities[targetIdentifier]; |
| if (!target) { |
| target = Object.values(bot.entities).find(e => e.username === targetIdentifier || e.name === targetIdentifier); |
| } |
| |
| if (target) { |
| const targetName = target.username || target.name || targetIdentifier; |
| log(`Engaging in combat with ${targetName} to the death!`, "info"); |
| |
| let targetDead = false; |
| const deathListener = (entity: any) => { |
| if (entity === target) targetDead = true; |
| }; |
| bot.on('entityDead', deathListener); |
| |
| const defaultMove = new Movements(bot); |
| defaultMove.allowSprinting = true; |
| bot.pathfinder.setMovements(defaultMove); |
| |
| let currentTargetEntity = target; |
| |
| bot.pathfinder.setGoal(new goals.GoalFollow(currentTargetEntity, 2), true); |
| |
| try { |
| |
| while (!targetDead && bot.health > 0) { |
| const freshTarget = bot.entities[currentTargetEntity.id]; |
| |
| if (!freshTarget || !freshTarget.isValid) { |
| targetDead = true; |
| break; |
| } |
|
|
| if (freshTarget !== currentTargetEntity) { |
| currentTargetEntity = freshTarget; |
| bot.pathfinder.setGoal(new goals.GoalFollow(currentTargetEntity, 2), true); |
| } |
| |
| bot.setControlState('sprint', true); |
| |
| const dist = bot.entity.position.distanceTo(currentTargetEntity.position); |
| |
| if (dist <= 3.5) { |
| bot.lookAt(currentTargetEntity.position.offset(0, 1.5, 0)); |
| bot.attack(currentTargetEntity); |
| await new Promise(r => setTimeout(r, 600)); |
| } else { |
| await new Promise(r => setTimeout(r, 100)); |
| } |
| } |
| } catch (e) { |
| log(`Combat interrupted`, "error"); |
| } finally { |
| bot.removeListener('entityDead', deathListener); |
| bot.pathfinder.setGoal(null); |
| bot.clearControlStates(); |
| } |
| log(`Finished fighting ${targetName}`, "success"); |
| } else { |
| log(`Cannot find target ${targetIdentifier}`, "error"); |
| } |
| } else if (action[0] === "follow") { |
| const targetIdentifier = action[1]; |
| let target = bot.players[targetIdentifier]?.entity || bot.entities[targetIdentifier]; |
| if (!target) { |
| target = Object.values(bot.entities).find(e => e.username === targetIdentifier || e.name === targetIdentifier); |
| } |
|
|
| if (target) { |
| const targetName = target.username || target.name || targetIdentifier; |
| log(`Following ${targetName}...`, "info"); |
| const defaultMove = new Movements(bot); |
| defaultMove.allowSprinting = true; |
| bot.pathfinder.setMovements(defaultMove); |
| |
| let currentTargetEntity = target; |
| bot.pathfinder.setGoal(new goals.GoalFollow(currentTargetEntity, 3), true); |
| |
| try { |
| |
| let followTicks = 0; |
| while (followTicks < 300 && bot.health > 0) { |
| const freshTarget = bot.entities[currentTargetEntity.id]; |
| if (!freshTarget || !freshTarget.isValid) break; |
|
|
| if (freshTarget !== currentTargetEntity) { |
| currentTargetEntity = freshTarget; |
| bot.pathfinder.setGoal(new goals.GoalFollow(currentTargetEntity, 3), true); |
| } |
| |
| const dist = bot.entity.position.distanceTo(currentTargetEntity.position); |
| if (dist > 5) bot.setControlState('sprint', true); |
| else bot.setControlState('sprint', false); |
| |
| await new Promise(r => setTimeout(r, 100)); |
| followTicks++; |
| } |
| } catch (e) { |
| log(`Could not reach ${targetName}`, "error"); |
| } finally { |
| bot.pathfinder.setGoal(null); |
| bot.clearControlStates(); |
| } |
| } else { |
| log(`Cannot find target ${targetIdentifier}`, "error"); |
| } |
| } else if (action.length >= 4) { |
| const [x, y, z, rawBlock] = action; |
| const targetPos = startPos.offset(x, y, z); |
| |
| |
| let blockName = String(rawBlock).toLowerCase(); |
| if (!blockName.startsWith('minecraft:')) { |
| blockName = `minecraft:${blockName}`; |
| } |
|
|
| |
| bot.chat(`/setblock ${Math.floor(targetPos.x)} ${Math.floor(targetPos.y)} ${Math.floor(targetPos.z)} ${blockName}`); |
| } |
| } catch (e) { |
| log(`Failed to execute action: ${JSON.stringify(action)}`, "error"); |
| } |
| } |
|
|
| bot.chat("Generation complete."); |
| log("Generation complete!", "success"); |
| } catch (error: any) { |
| log(`AI Error: ${error.message}`, "error"); |
| bot.chat(`Error during generation: ${error.message}`); |
| } |
| } |
|
|
| |
| if (process.env.NODE_ENV !== "production") { |
| const vite = await createViteServer({ |
| server: { middlewareMode: true }, |
| appType: "spa", |
| }); |
| app.use(vite.middlewares); |
| } else { |
| const distPath = path.join(process.cwd(), "dist"); |
| app.use(express.static(distPath)); |
| app.get("*", (req, res) => { |
| res.sendFile(path.join(distPath, "index.html")); |
| }); |
| } |
|
|
| httpServer.listen(PORT, "0.0.0.0", () => { |
| log(`Server running on http://localhost:${PORT}`, "success"); |
| }); |
| } |
|
|
| startServer(); |