"use strict"; // A second, independent Mineflayer player. Gemini CLI proposes only one // constrained game action; no generated JavaScript or shell command is run. const http = require("http"); const { execFile } = require("child_process"); const mineflayer = require("mineflayer"); const { pathfinder, Movements, goals: { GoalNear } } = require("mineflayer-pathfinder"); const mcData = require("minecraft-data"); const Vec3 = require("vec3"); const { DIRECTIONS, MINE_PALETTE, parseDecision, extractCliResponse, tokenUsage, makePrompt } = require("./gemini_bot_logic"); const USERNAME = "Gemini_Bot"; const ADMIN = "IffyBinkie6134"; const MODEL = "gemini-3.5-flash-lite"; const KEYS = [1, 2, 3, 4, 5, 6].map(i => process.env[`GEMINI_BOT_KEY_${i}`]).filter(Boolean); const CLI = process.env.GEMINI_BOT_CLI; const WORKSPACE = process.env.GEMINI_BOT_WORKSPACE; const PORT = Number(process.env.GEMINI_BOT_STATUS_PORT || "3001"); const state = { ready: false, busy: false, reconnect_count: 0, completed_actions: 0, failed_actions: 0, cli_calls: 0, last_action: null, last_error: null, last_elapsed_s: null, prompt_tokens: null, completion_tokens: null, models_used: [], model: MODEL, bot_username: USERNAME }; let bot = null; let reconnectTimer = null; let busy = false; let selectedKey = 0; let lastChat = 0; let lastDecision = 0; let closing = false; function event(kind, detail = {}) { console.log(JSON.stringify({ at: new Date().toISOString(), kind, ...detail })); } function observation() { const p = bot.entity.position; const nearby = new Set(); for (let dx = -4; dx <= 4; dx += 2) for (let dz = -4; dz <= 4; dz += 2) { for (let dy = -1; dy <= 2; dy++) { const block = bot.blockAt(p.floored().offset(dx, dy, dz)); if (block && block.name !== "air" && !block.name.endsWith("_air")) nearby.add(block.name); } } const players = {}; for (const name of [ADMIN, "Naldo_Bot"]) { const e = bot.players[name] && bot.players[name].entity; if (e) players[name] = { x: Math.round(e.position.x), y: Math.round(e.position.y), z: Math.round(e.position.z), distance: Math.round(e.position.distanceTo(p)) }; } // Avoid repeatedly digging a hole in the persistent terrain. A new mine // action becomes available after a distinct move, placement, or follow. const safeMineBlocks = state.last_action && state.last_action.action === "mine" ? [] : [...nearby].filter(name => MINE_PALETTE.has(name) && Boolean(safeMineTarget(name))); return { position: { x: Math.round(p.x), y: Math.round(p.y), z: Math.round(p.z) }, nearby_blocks: [...nearby].slice(0, 32), safe_mine_blocks: safeMineBlocks, safe_place_possible: Boolean(safePlaceSupport()), players, last_action: state.last_action, last_error: state.last_error }; } function callCli(prompt) { return new Promise((resolve, reject) => { if (!KEYS.length || !CLI) return reject(new Error("Gemini CLI credential or executable unavailable")); const start = Date.now(); const keySlot = selectedKey % KEYS.length; const env = { PATH: process.env.PATH || "/usr/bin:/bin", HOME: process.env.GEMINI_BOT_HOME, GEMINI_CLI_HOME: process.env.GEMINI_BOT_HOME, GEMINI_CLI_TRUST_WORKSPACE: "true", GEMINI_SYSTEM_MD: process.env.GEMINI_BOT_SYSTEM_MD, GEMINI_TELEMETRY_ENABLED: "false", GEMINI_TELEMETRY_LOG_PROMPTS: "false", LANG: "C.UTF-8", GEMINI_API_KEY: KEYS[keySlot] }; state.cli_calls++; execFile(CLI, ["--model", MODEL, "--output-format", "json", "--approval-mode", "plan", "--prompt", prompt], { cwd: WORKSPACE, env, timeout: 120000, maxBuffer: 512 * 1024 }, (err, stdout) => { state.last_elapsed_s = Math.round((Date.now() - start) / 100) / 10; if (err) { // stderr can contain credential-bearing diagnostic text; never log it. selectedKey = (selectedKey + 1) % KEYS.length; let code = String(err.code || "unknown"); try { const reported = JSON.parse(stdout).error; if (reported && Number.isInteger(reported.code)) code += ` API ${reported.code}`; } catch (_) { /* stderr and raw error messages remain private */ } return reject(new Error(`Gemini CLI exit ${code}`)); } try { const result = extractCliResponse(stdout); const usage = tokenUsage(result.stats); state.prompt_tokens = usage.prompt_tokens; state.completion_tokens = usage.completion_tokens; state.models_used = usage.models_used; resolve(result.decision); } catch (_) { reject(new Error("Gemini CLI response malformed")); } }); }); } async function travel(x, y, z, timeoutMs = 22000) { const start = bot.entity.position.clone(); const currentBot = bot; await new Promise((resolve, reject) => { let done = false; const finish = () => { if (done) return; done = true; clearTimeout(timer); currentBot.removeListener("goal_reached", finish); try { currentBot.pathfinder.setGoal(null); } catch (_) { /* disconnected */ } resolve(); }; const timer = setTimeout(finish, timeoutMs); currentBot.on("goal_reached", finish); try { currentBot.pathfinder.setGoal(new GoalNear(x, y, z, 2)); } catch (err) { clearTimeout(timer); currentBot.removeListener("goal_reached", finish); reject(err); } }); if (!currentBot.entity || currentBot.entity.position.distanceTo(start) < 2) throw new Error("movement not observed"); return Math.round(currentBot.entity.position.distanceTo(start)); } function awayFromPlayers(position, minDistance) { return [ADMIN, "Naldo_Bot"].every(name => { const e = bot.players[name] && bot.players[name].entity; return !e || e.position.distanceTo(position) >= minDistance; }); } function safeMineTarget(name) { const data = bot.registry.blocksByName[name]; if (!data || !bot.entity) return null; try { const positions = bot.findBlocks({ matching: data.id, maxDistance: 5, count: 24 }); for (const position of positions) { if (!awayFromPlayers(position, 3)) continue; const block = bot.blockAt(position); if (block && block.name === name) return block; } } catch (_) { /* unloaded chunk */ } return null; } function safePlaceSupport() { if (!bot.entity) return null; const p = bot.entity.position.floored(); for (const [dx, dz] of [[2, 0], [-2, 0], [0, 2], [0, -2], [3, 0], [-3, 0], [0, 3], [0, -3]]) { for (const dy of [-1, 0, -2]) { const base = bot.blockAt(p.offset(dx, dy, dz)); if (!base || !["grass_block", "dirt", "stone", "sand"].includes(base.name)) continue; const destination = bot.blockAt(base.position.offset(0, 1, 0)); if (destination && destination.name === "air" && awayFromPlayers(destination.position, 4)) return base; } } return null; } async function applyDecision(decision, observed) { if (!bot || !bot.entity) throw new Error("bot disconnected"); if (decision.action === "wait") return { action: "wait" }; if (decision.action === "say") { if (Date.now() - lastChat < 180000) throw new Error("chat rate limited"); bot.chat(decision.message); lastChat = Date.now(); return { action: "say" }; } if (decision.action === "explore") { const [dx, dz] = DIRECTIONS[decision.direction]; const p = bot.entity.position; const moved = await travel(Math.floor(p.x) + dx * decision.blocks, Math.floor(p.y), Math.floor(p.z) + dz * decision.blocks); return { action: "explore", direction: decision.direction, moved }; } if (decision.action === "follow") { const target = bot.players[decision.player] && bot.players[decision.player].entity; if (!target) throw new Error("follow target offline"); const p = target.position; const moved = await travel(Math.floor(p.x), Math.floor(p.y), Math.floor(p.z), 15000); return { action: "follow", moved }; } if (decision.action === "mine") { if (!observed.safe_mine_blocks.includes(decision.block)) throw new Error("block not safe in observation"); const target = safeMineTarget(decision.block); if (!target) throw new Error("no safe nearby block"); await bot.dig(target); const actual = bot.blockAt(target.position); if (actual && actual.name === decision.block) throw new Error("mined block unchanged"); return { action: "mine", block: decision.block, changed: true }; } if (decision.action === "place") { const datum = bot.registry.itemsByName[decision.block]; if (!datum || bot.game.gameMode !== "creative") throw new Error("Creative item unavailable"); const support = safePlaceSupport(); if (!support) throw new Error("no safe placement location"); const Item = require("prismarine-item")(bot.registry); await bot.creative.setInventorySlot(36, new Item(datum.id, 1)); await bot.equip(bot.inventory.slots[36] || datum.id, "hand"); const destination = support.position.offset(0, 1, 0); await bot.placeBlock(support, new Vec3(0, 1, 0)); const actual = bot.blockAt(destination); if (!actual || actual.name !== decision.block) throw new Error("placed block not observed"); return { action: "place", block: decision.block, changed: true }; } throw new Error("invalid action"); } async function decide(request = null) { if (busy || !state.ready || Date.now() - lastDecision < 12000) return; busy = state.busy = true; lastDecision = Date.now(); try { const activeBot = bot; const observed = observation(); let decision; for (let attempt = 0; attempt < Math.min(KEYS.length, 2); attempt++) { try { decision = await callCli(makePrompt(observed, request)); break; } catch (err) { if (!err.message.startsWith("Gemini CLI exit") || attempt + 1 >= Math.min(KEYS.length, 2)) throw err; } } if (bot !== activeBot || !state.ready) throw new Error("bot reconnected during decision"); const result = await applyDecision(decision, observed); state.last_action = result; state.last_error = null; state.completed_actions++; event("action", { result, elapsed_s: state.last_elapsed_s }); } catch (err) { state.failed_actions++; state.last_error = String(err.message).slice(0, 100); event("action_failed", { reason: state.last_error }); } finally { busy = state.busy = false; } } function connect() { if (closing) return; state.ready = false; const current = mineflayer.createBot({ host: "127.0.0.1", port: 25565, username: USERNAME, version: "1.19", auth: "offline" }); bot = current; current.loadPlugin(pathfinder); current.once("spawn", () => { if (bot !== current) return; const movements = new Movements(current, mcData(current.version)); current.pathfinder.setMovements(movements); state.ready = true; event("spawn", { username: USERNAME, position: observation().position }); setTimeout(() => decide(), 15000); }); current.on("chat", (name, message) => { if (name !== ADMIN || !/^\s*(?:@?Gemini_Bot)[,:\s]/i.test(message)) return; decide(message.replace(/^\s*@?Gemini_Bot[,:\s]*/i, "")); }); current.on("error", err => event("connection_error", { code: err.code || err.name || "unknown" })); current.on("kicked", () => event("kicked")); current.on("end", () => { if (bot !== current || closing) return; state.ready = false; state.reconnect_count++; event("disconnected", { reconnect_count: state.reconnect_count }); clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connect, Math.min(30000, 3000 * state.reconnect_count)); }); } http.createServer((req, res) => { if (req.url !== "/health") { res.writeHead(404); return res.end(); } res.setHeader("Content-Type", "application/json"); let position = null; try { if (state.ready) position = observation().position; } catch (_) { /* reconnection in progress */ } res.end(JSON.stringify({ ...state, position })); }).listen(PORT, "127.0.0.1"); setInterval(() => decide(), 90000).unref(); process.on("SIGTERM", () => { closing = true; clearTimeout(reconnectTimer); if (bot) bot.quit(); process.exit(0); }); event("starting", { model: MODEL, key_count: KEYS.length }); connect();