"use strict"; const { spawn } = require("child_process"); const fs = require("fs"); const path = require("path"); const { WebSocket } = require("ws"); // ─── config ─────────────────────────────────────────────────────────────────── const USERNAMES = ["ZareMate", "Tomek", "Cytrus", "Qawe"]; const AFK_SCRIPT = path.join(__dirname, "afk.js"); const OUTPUT_MODE = ( process.env.PARENT_OUTPUT_MODE || "prefixed" ).toLowerCase(); const SPLIT_LOGS_ENABLED = process.env.PARENT_SPLIT_LOGS === "1"; // URL of the control server's parent endpoint. const SERVER_WS_URL = process.env.AFK_SERVER_WS_URL || `ws://server.suchodupin.com:${Number(process.env.AFK_PARENT_PORT || 3008)}/parent`; // Optional shared secret matching PARENT_SECRET on server.js const PARENT_SECRET = process.env.PARENT_SECRET || ""; const PARENT_ID = process.env.AFK_PARENT_ID || `${process.env.HOSTNAME || "parent"}-${process.pid}`; const RESTART_MIN_MS = 2 * 60 * 1000; const RESTART_MAX_MS = 5 * 60 * 1000; const STARTUP_GAP_MIN_MS = 15 * 1000; const STARTUP_GAP_MAX_MS = 30 * 1000; const RECONNECT_DELAY_MS = 3_000; const ALREADY_ONLINE_TEXT = "You are already online, try restarting your game."; const TIMEOUT_REGEX = /timeout|timed out|ETIMEDOUT|ECONNRESET|socket hang up/i; // ─── globals ────────────────────────────────────────────────────────────────── let isShuttingDown = false; let requestCounter = 0; let serverWs = null; let reconnectTimer = null; // ─── helpers ────────────────────────────────────────────────────────────────── function toSafeFilePart(value) { return String(value).replace(/[^a-zA-Z0-9_-]/g, "_"); } function writeSplitLog(logFile, line) { if (!logFile) return; const timestamp = new Date().toISOString(); fs.appendFileSync(logFile, `[${timestamp}] ${line}\n`); } function withUsernamePrefix(username, line) { const prefix = `[${username}] `; if (line.startsWith(prefix)) return line; return `${prefix}${line}`; } function randomRestartDelayMs() { return ( RESTART_MIN_MS + Math.floor(Math.random() * (RESTART_MAX_MS - RESTART_MIN_MS + 1)) ); } function randomStartupGapMs() { return ( STARTUP_GAP_MIN_MS + Math.floor(Math.random() * (STARTUP_GAP_MAX_MS - STARTUP_GAP_MIN_MS + 1)) ); } function shouldRestartFromState(state) { return state.sawKick || state.sawTimeout || state.sawAlreadyOnline; } function updateRestartSignalsFromLine(state, line) { if (!line) return; if (line.includes("Kicked:")) state.sawKick = true; if (TIMEOUT_REGEX.test(line)) state.sawTimeout = true; if (line.includes(ALREADY_ONLINE_TEXT)) state.sawAlreadyOnline = true; } function nowIso() { return new Date().toISOString(); } function parseLineChunks(pushLine) { let buffer = ""; return (chunk, flush = false) => { const text = buffer + chunk.toString("utf8"); const lines = text.split(/\r?\n/); buffer = lines.pop() || ""; for (const line of lines) { if (!line) continue; pushLine(line); } if (flush && buffer) { pushLine(buffer); buffer = ""; } }; } // ─── bot state ──────────────────────────────────────────────────────────────── const botStates = USERNAMES.map((username) => ({ username, child: null, restartTimer: null, startupTimer: null, sawKick: false, sawTimeout: false, sawAlreadyOnline: false, status: "stopped", pid: null, window: null, lastError: null, userStopped: false, pendingRequests: new Map(), })); const botStateByUsername = new Map(botStates.map((s) => [s.username, s])); function serializeState(state) { return { username: state.username, status: state.status, pid: state.pid, running: Boolean(state.child), userStopped: state.userStopped, sawKick: state.sawKick, sawTimeout: state.sawTimeout, sawAlreadyOnline: state.sawAlreadyOnline, lastError: state.lastError, window: state.window, }; } function resolveState(username) { const key = String(username || "").trim(); if (!key) return null; return botStateByUsername.get(key) || null; } // ─── server WS communication ────────────────────────────────────────────────── function sendToServer(payload) { if (!serverWs || serverWs.readyState !== WebSocket.OPEN) return; try { serverWs.send( JSON.stringify({ ...payload, parentId: payload.parentId || PARENT_ID }), ); } catch (_) { /* ignore */ } } function broadcast(payload) { sendToServer(payload); } function broadcastState(state) { broadcast({ type: "bot.update", serverTime: nowIso(), bot: serializeState(state), }); } // ─── forward parent console logs to server UI ──────────────────────────────── // Patch after sendToServer is defined. Lines emitted before the WS connects // are silently dropped (sendToServer is a no-op when not connected). { const _log = console.log.bind(console); const _err = console.error.bind(console); const strip = (s) => s.replace(/^\[parent\] /, ""); console.log = (...args) => { const line = args.map(String).join(" "); _log(line); sendToServer({ type: "parent.log", serverTime: nowIso(), stream: "stdout", line: strip(line), }); }; console.error = (...args) => { const line = args.map(String).join(" "); _err(line); sendToServer({ type: "parent.log", serverTime: nowIso(), stream: "stderr", line: strip(line), }); }; } // ─── child log handling ─────────────────────────────────────────────────────── function onChildLogLine(state, streamName, line) { updateRestartSignalsFromLine(state, line); const rendered = OUTPUT_MODE === "raw" ? line : withUsernamePrefix(state.username, line); if (streamName === "stderr") process.stderr.write(`${rendered}\n`); else process.stdout.write(`${rendered}\n`); const logFile = SPLIT_LOGS_ENABLED ? path.join(__dirname, `afk-parent-${toSafeFilePart(state.username)}.log`) : null; writeSplitLog(logFile, `${streamName}: ${line}`); broadcast({ type: "bot.log", serverTime: nowIso(), username: state.username, stream: streamName, line, }); } // ─── timer helpers ──────────────────────────────────────────────────────────── function clearTimers(state) { if (state.startupTimer) { clearTimeout(state.startupTimer); state.startupTimer = null; } if (state.restartTimer) { clearTimeout(state.restartTimer); state.restartTimer = null; } } function rejectPendingRequests(state, errorMessage) { for (const { reject, timeoutId } of state.pendingRequests.values()) { clearTimeout(timeoutId); reject(new Error(errorMessage || "Bot process ended")); } state.pendingRequests.clear(); } // ─── child IPC ──────────────────────────────────────────────────────────────── function handleChildIpcMessage(state, msg) { if (!msg || typeof msg !== "object") return; if (msg.type === "windowSnapshot") { state.window = msg.window || null; broadcastState(state); return; } if (msg.type === "botStatus") { if (typeof msg.status === "string" && msg.status.trim()) state.status = msg.status; if (Number.isInteger(msg.pid)) state.pid = msg.pid; broadcastState(state); return; } if (msg.type === "botError") { state.lastError = String(msg.error || "Unknown bot error"); broadcastState(state); return; } if (msg.type === "botEvent") { broadcast({ type: "bot.event", serverTime: nowIso(), username: state.username, event: msg.event || "unknown", reason: msg.reason || null, }); return; } if (msg.type === "actionResult") { const req = state.pendingRequests.get(msg.requestId); if (!req) return; clearTimeout(req.timeoutId); state.pendingRequests.delete(msg.requestId); if (msg.ok) req.resolve(msg); else req.reject(new Error(msg.error || "Bot action failed")); } } // ─── bot lifecycle ──────────────────────────────────────────────────────────── function startBot(state, options = {}) { if (state.child) return state.child; state.userStopped = false; state.sawKick = false; state.sawTimeout = false; state.sawAlreadyOnline = false; state.lastError = null; state.window = null; state.status = "starting"; clearTimers(state); broadcastState(state); const child = spawn(process.execPath, [AFK_SCRIPT, state.username], { cwd: __dirname, stdio: ["ignore", "pipe", "pipe", "ipc"], }); state.child = child; state.pid = child.pid; const src = options.source ? ` source=${options.source}` : ""; console.log(`[parent] started ${state.username} (pid=${child.pid})${src}`); const consumeStdout = parseLineChunks((line) => onChildLogLine(state, "stdout", line), ); const consumeStderr = parseLineChunks((line) => onChildLogLine(state, "stderr", line), ); child.stdout.on("data", (chunk) => consumeStdout(chunk)); child.stderr.on("data", (chunk) => consumeStderr(chunk)); child.stdout.on("close", () => consumeStdout(Buffer.from(""), true)); child.stderr.on("close", () => consumeStderr(Buffer.from(""), true)); child.on("message", (msg) => handleChildIpcMessage(state, msg)); child.on("exit", (code, signal) => { const reason = signal ? `signal ${signal}` : `code ${code}`; console.log(`[parent] ${state.username} exited (${reason})`); rejectPendingRequests(state, `Bot ${state.username} exited`); state.child = null; state.pid = null; state.window = null; state.status = "stopped"; broadcastState(state); if (isShuttingDown) return; if (state.userStopped) return; if (!shouldRestartFromState(state)) return; const delay = randomRestartDelayMs(); console.log( `[parent] scheduling restart for ${state.username} in ${Math.round(delay / 1000)}s`, ); state.restartTimer = setTimeout(() => { state.restartTimer = null; if (isShuttingDown) return; if (state.userStopped) return; startBot(state, { source: "restart" }); }, delay); state.status = "restart-wait"; broadcastState(state); }); child.on("error", (err) => { state.lastError = err.message; console.error(`[parent] failed to start ${state.username}: ${err.message}`); broadcastState(state); }); broadcastState(state); try { child.send({ type: "state.request", requestId: `bootstrap-${Date.now()}` }); } catch { /* child may have already exited */ } return child; } function stopBot(state, options = {}) { const source = options.source || "manual"; const setUserStopped = options.userStopped !== false; clearTimers(state); if (setUserStopped) state.userStopped = true; if (!state.child) { state.status = "stopped"; broadcastState(state); return; } console.log(`[parent] stopping ${state.username} source=${source}`); state.status = "stopping"; broadcastState(state); if (!state.child.killed) state.child.kill("SIGINT"); } function sendCommandToBot(state, payload, timeoutMs = 10_000) { if (!state.child) { return Promise.reject(new Error(`Bot ${state.username} is not running`)); } requestCounter += 1; const requestId = `${state.username}-${Date.now()}-${requestCounter}`; return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { state.pendingRequests.delete(requestId); reject(new Error("Bot action timed out")); }, timeoutMs); state.pendingRequests.set(requestId, { resolve, reject, timeoutId }); try { state.child.send({ ...payload, requestId }); } catch (err) { clearTimeout(timeoutId); state.pendingRequests.delete(requestId); reject(err); } }); } // ─── handle messages from the control server ────────────────────────────────── async function handleServerMessage(msg) { if (!msg || typeof msg !== "object") return; const relayId = msg._relayId || null; const reply = (ok, extra = {}) => { sendToServer({ type: "ack", _relayId: relayId, ok, ...extra }); }; if (msg.type === "state.get") { sendToServer({ type: "state.full", _relayId: relayId, serverTime: nowIso(), bots: botStates.map(serializeState), }); return; } const state = resolveState(msg.username); if (!state) { reply(false, { error: "Unknown bot username" }); return; } try { if (msg.type === "bot.start") { startBot(state, { source: "web" }); reply(true); return; } if (msg.type === "bot.stop") { stopBot(state, { source: "web", userStopped: true }); reply(true); return; } if (msg.type === "bot.chat") { await sendCommandToBot(state, { type: "chat", message: msg.message }); reply(true); return; } if (msg.type === "bot.window.click") { await sendCommandToBot(state, { type: "window.click", slot: msg.slot, mouseButton: msg.mouseButton, mode: msg.mode, }); reply(true); return; } if (msg.type === "bot.window.close") { await sendCommandToBot(state, { type: "window.close" }); reply(true); return; } reply(false, { error: "Unknown message type" }); } catch (err) { reply(false, { error: err.message || "Action failed" }); } } // ─── server WebSocket client ────────────────────────────────────────────────── function connectToServer() { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (isShuttingDown) return; const wsUrl = new URL(SERVER_WS_URL); wsUrl.searchParams.set("parentId", PARENT_ID); if (PARENT_SECRET) wsUrl.searchParams.set("secret", PARENT_SECRET); const url = wsUrl.toString(); const ws = new WebSocket(url); ws.on("open", () => { serverWs = ws; console.log(`[parent] connected to server as ${PARENT_ID}`); // Push current bot state so server cache is fresh sendToServer({ type: "state.full", serverTime: nowIso(), bots: botStates.map(serializeState), }); }); ws.on("message", (raw) => { let msg; try { msg = JSON.parse(String(raw)); } catch { return; } handleServerMessage(msg); }); ws.on("close", () => { if (serverWs === ws) serverWs = null; if (!isShuttingDown) { console.log( `[parent] server connection lost, reconnecting in ${RECONNECT_DELAY_MS / 1000}s`, ); reconnectTimer = setTimeout(connectToServer, RECONNECT_DELAY_MS); } }); ws.on("error", (err) => { // 'close' fires after 'error'; reconnect is handled there console.error(`[parent] server ws error: ${err.message}`); ws.terminate(); }); } // ─── startup ────────────────────────────────────────────────────────────────── connectToServer(); let startupOffset = 0; for (const state of botStates) { const delay = startupOffset; state.status = delay > 0 ? "startup-wait" : "starting"; state.startupTimer = setTimeout(() => { state.startupTimer = null; if (isShuttingDown) return; if (state.userStopped) return; startBot(state, { source: "startup" }); }, delay); if (delay > 0) { console.log( `[parent] startup delay for ${state.username}: ${Math.round(delay / 1000)}s`, ); } startupOffset += randomStartupGapMs(); } // ─── shutdown ───────────────────────────────────────────────────────────────── function shutdownAll() { if (isShuttingDown) return; isShuttingDown = true; console.log("[parent] shutdown requested"); for (const state of botStates) { stopBot(state, { source: "shutdown", userStopped: false }); } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (serverWs) serverWs.close(); } process.on("SIGINT", shutdownAll); process.on("SIGTERM", shutdownAll);