naldo
Prevent repetitive terrain mining by Gemini bot
ce56c04
Raw History Blame Contribute Delete
12.2 kB
"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();