'use strict';
/**
* AutoStore Bot Web 控制台前端逻辑。
*
* 六块职责:
* 1. 控制台登录(登录验证通过后才允许操作各机器人的控制台 / 聊天框);
* 2. 通过 WebSocket 接收实时状态、运行日志、公屏消息与开拓记录;
* 3. 渲染多机器人列表与选中机器人的状态面板;
* 4. 根据配置 schema 动态生成配置表单(支持「仅对当前机器人生效」的覆盖模式);
* 5. Canvas 绘制领土地图(区块级)与大地图(爬取服务器地图瓦片 + 领土多边形);
* 6. 游戏公屏聊天框与开拓日志表格。
*/
// ==================== 工具 ====================
const $ = (id) => document.getElementById(id);
/** 控制台凭证:优先 URL 上的 ?token=,其次浏览器本地保存(登录框写入) */
let TOKEN = new URLSearchParams(location.search).get('token') || localStorage.getItem('autostore_token') || '';
/** 当前选中的地图图幅(半径,方块) */
function selectedRangeBlocks() {
const el = $('bigMapRange');
const n = Number(el && el.value);
return Number.isFinite(n) && n > 0 ? n : 4096;
}
/**
* 大地图 / 领土地图应该聚焦的位置:当前选中机器人的坐标。
*
* 这两张图的用途都是「看自己的机器人」,所以默认必须跟着机器人走,
* 而不是永远停在某个固定坐标上。
*
* @returns {{x: number, z: number}|null}
*/
function botFocusPosition() {
const bots = ((lastStatus && lastStatus.bots) || []).filter((b) => b.position);
if (bots.length === 0) return null;
const target = bots.find((b) => b.id === selectedBotId) || bots[0];
return { x: target.position.x, z: target.position.z };
}
function setToken(value) {
TOKEN = value || '';
try {
if (TOKEN) localStorage.setItem('autostore_token', TOKEN);
else localStorage.removeItem('autostore_token');
} catch {
// 隐私模式下 localStorage 可能不可用,忽略
}
}
function api(path, options) {
const opts = options || {};
const headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
if (TOKEN) headers['X-Auth-Token'] = TOKEN;
return fetch(path, Object.assign({}, opts, { headers })).then(async (res) => {
let body = null;
try {
body = await res.json();
} catch {
body = { ok: false, message: 'HTTP ' + res.status };
}
if (res.status === 401) {
showLogin('登录已失效,请重新登录');
throw new Error(body.message || '未授权');
}
if (!res.ok || body.ok === false) {
throw new Error(body.message || ('HTTP ' + res.status));
}
return body;
});
}
let toastTimer = null;
function toast(message, isError) {
const el = $('toast');
el.textContent = message;
el.className = 'toast show' + (isError ? ' error' : '');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.className = 'toast'; }, 3500);
}
const pad2 = (n) => String(n).padStart(2, '0');
function fmtTime(ts) {
if (!ts) return '-';
const d = new Date(ts);
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
}
function fmtDateTime(ts) {
if (!ts) return '-';
const d = new Date(ts);
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} `
+ `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
}
function fmtAgo(ts) {
if (!ts) return '从未';
const seconds = Math.floor((Date.now() - ts) / 1000);
if (seconds < 5) return '刚刚';
if (seconds < 60) return seconds + ' 秒前';
if (seconds < 3600) return Math.floor(seconds / 60) + ' 分钟前';
return fmtTime(ts);
}
function hexToRgba(hex, alpha) {
const value = String(hex).replace('#', '');
const r = parseInt(value.slice(0, 2), 16) || 0;
const g = parseInt(value.slice(2, 4), 16) || 0;
const b = parseInt(value.slice(4, 6), 16) || 0;
return `rgba(${r},${g},${b},${alpha})`;
}
function withToken(path) {
if (!TOKEN) return path;
return path + (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN);
}
// ==================== 配置 schema ====================
const STRATEGY_OPTIONS = [
{ value: 1, label: '1 - 方形外扩(优先填洞)' },
{ value: 2, label: '2 - 区域扩地(S 形扫描)' },
{ value: 3, label: '3 - 玩家周围扩地' },
{ value: 4, label: '4 - 绕国一圈' }
];
const WATER_MODE_OPTIONS = [
{ value: 'avoid', label: 'avoid - 绕开水域(推荐)' },
{ value: 'swim', label: 'swim - 游泳通过' },
{ value: 'boat', label: 'boat - 放船渡水(Grim 风险高)' }
];
/**
* 配置表单 schema:每个 tab 下若干分组,每组若干字段。
* path 为配置对象里的点分路径。
*/
const CONFIG_SCHEMA = {
strategy: [
{
title: '扩地策略',
fields: [
{ path: 'strategy', label: '策略', type: 'select', options: STRATEGY_OPTIONS },
{ path: 'stateName', label: '目标邦国', type: 'text', hint: '留空则自动识别机器人所在邦国;多机器人时建议各自指定' },
{ path: 'targetCount', label: '目标扩地数量', type: 'number', min: -1, hint: '-1 表示持续扩地直到无可用区块' },
{ path: 'maxRings', label: '扩圈上限', type: 'number', min: 0, hint: '0 表示不限制' }
]
},
{
title: '区域扩地范围(仅策略 2)',
fields: [
{ path: 'regionMinX', label: '矩形 minX(区块)', type: 'number' },
{ path: 'regionMaxX', label: '矩形 maxX(区块)', type: 'number', hint: 'maxX < minX 表示未圈选,改用中心 + 半径' },
{ path: 'regionMinZ', label: '矩形 minZ(区块)', type: 'number' },
{ path: 'regionMaxZ', label: '矩形 maxZ(区块)', type: 'number' },
{ path: 'regionCenterX', label: '中心 X(区块)', type: 'number' },
{ path: 'regionCenterZ', label: '中心 Z(区块)', type: 'number' },
{ path: 'regionRadius', label: '半径(区块)', type: 'number', min: 1 }
]
}
],
timing: [
{
title: '超时与重试',
fields: [
{ path: 'waitTimeoutSeconds', label: '等待 / 寻路超时(秒)', type: 'number', min: 5 },
{ path: 'retries', label: '超时后最大重试次数', type: 'number', min: 1 },
{ path: 'returnThresholdSeconds', label: '连续超时回城阈值(秒)', type: 'number', min: 0 },
{ path: 'returnWaitSeconds', label: '回城等待传送(秒)', type: 'number', min: 1 },
{ path: 'sideStepMaxRounds', label: '往边上走两步最大轮数', type: 'number', min: 0 }
]
},
{
title: '开垦时段限制',
fields: [
{ path: 'enforceClaimTime', label: '启用时段限制', type: 'checkbox' },
{ path: 'claimStartHour', label: '起始小时(含)', type: 'number', min: 0, max: 23 },
{ path: 'claimEndHour', label: '结束小时(不含)', type: 'number', min: 1, max: 24 }
]
}
],
pathing: [
{
title: '寻路行为',
fields: [
{ path: 'allowSprint', label: '允许疾跑', type: 'checkbox' },
{ path: 'allowSwim', label: '允许游泳', type: 'checkbox' },
{ path: 'allowJump', label: '允许跳跃', type: 'checkbox' },
{ path: 'allowBreak', label: '允许破坏方块', type: 'checkbox', hint: 'Grim 对破坏速度检查严格,默认关闭' },
{ path: 'allowPlace', label: '允许放置方块', type: 'checkbox', hint: 'Grim 对放置检查严格,默认关闭' },
{ path: 'altitudePreference', label: '高度偏好(优先走高处)', type: 'checkbox' },
{ path: 'altitudeWeight', label: '高度偏好权重', type: 'number', step: 0.01, min: 0 }
]
},
{
title: '寻路器专属',
fields: [
{ path: 'pathing.allowParkour', label: '允许跑酷跳跃', type: 'checkbox', hint: 'mineflayer 起跳时机与原版有偏差,默认关闭' },
{ path: 'pathing.allow1by1towers', label: '允许 1×1 搭塔', type: 'checkbox' },
{ path: 'pathing.maxDropDown', label: '最大下落高度(格)', type: 'number', min: 0 },
{ path: 'pathing.liquidCost', label: '液体格额外代价', type: 'number', min: 1 },
{ path: 'pathing.canOpenDoors', label: '允许开门', type: 'checkbox' },
{ path: 'pathing.maxSegmentBlocks', label: '单段寻路最长距离(格)', type: 'number', min: 32, hint: '寻路器只能规划已加载区块范围(本服约 130 格),长距离会自动拆段;此项只是单段上限' },
{ path: 'pathing.segmentMarginBlocks', label: '分段安全边距(格)', type: 'number', min: 0, hint: '分段时距已加载范围边缘留出的余量,避免踩在未加载边界上' }
]
}
],
survival: [
{
title: '生存辅助',
fields: [
{ path: 'avoidMobs', label: '避开怪物', type: 'checkbox' },
{ path: 'autoEat', label: '自动进食', type: 'checkbox' },
{ path: 'eatThreshold', label: '进食饥饿阈值', type: 'number', min: 0, max: 20 },
{ path: 'autoAttack', label: '自动攻击敌对生物', type: 'checkbox' },
{ path: 'durabilityGuard', label: '耐久保护', type: 'checkbox' },
{ path: 'durabilityThreshold', label: '耐久保护阈值(%)', type: 'number', min: 1, max: 99 }
]
},
{
title: '战斗筛选',
fields: [
{ path: 'combat.scanRange', label: '索敌半径(格)', type: 'number', step: 0.5, min: 1 },
{ path: 'combat.attackEnderman', label: '攻击末影人', type: 'checkbox', hint: 'mineflayer 读不到「是否被激怒」,默认不打' },
{ path: 'combat.attackSpiders', label: '攻击蜘蛛', type: 'checkbox', hint: 'mineflayer 读不到环境亮度,默认不打' },
{ path: 'combat.attackPiglins', label: '攻击猪灵(未穿金甲时)', type: 'checkbox' },
{ path: 'combat.avoidDangerousMobs', label: '跳过监守者 / 末影龙 / 凋灵', type: 'checkbox' }
]
},
{
title: '水域处理',
fields: [
{ path: 'waterMode', label: '水域策略', type: 'select', options: WATER_MODE_OPTIONS },
{ path: 'autoBoat', label: '启用自动用船', type: 'checkbox', hint: 'mineflayer 无载具物理,Grim 的 Vehicle 检查风险高,默认关闭' },
{ path: 'autoBoatMinWidth', label: '触发用船的最小水面宽度(格)', type: 'number', min: 1 },
{ path: 'autoBoatMinLength', label: '触发用船的最短水域长度(格)', type: 'number', min: 1 }
]
}
],
data: [
{
title: '地图数据源',
fields: [
{ path: 'refreshIntervalSeconds', label: '地图刷新间隔(秒)', type: 'number', min: 10 }
]
},
{
title: 'SimpCore 开放 API',
fields: [
{ path: 'apiEnabled', label: '启用 API 数据源', type: 'checkbox' },
{ path: 'apiKey', label: 'API Key', type: 'password', hint: '留空则回退读环境变量 SIMP_API_KEY' },
{ path: 'apiRefreshIntervalSeconds', label: 'API 轮询间隔(秒)', type: 'number', min: 60 },
{ path: 'apiV2Enabled', label: '启用 API-V2(实时状态)', type: 'checkbox', hint: 'V2 是独立的一套密钥体系,需在密钥中心单独申请' },
{ path: 'apiV2Key', label: 'API-V2 Key', type: 'password', hint: '留空则回退读环境变量 SIMP_API_V2_KEY;V2 配额很小,不做自动轮询' }
]
}
],
bigmap: [
{
title: '大地图(爬取服务器地图站点)',
fields: [
{ path: 'bigMap.enabled', label: '启用大地图', type: 'checkbox' },
{ path: 'bigMap.world', label: '世界目录名', type: 'text', hint: '留空则回退环境变量 DYNMAP_WORLD' },
{ path: 'bigMap.zoom', label: '启动预爬取层级', type: 'number', min: 0, max: 8, hint: '0 = 全世界一张大图(本站 10×10=100 张瓦片)' },
{ path: 'bigMap.maxZoom', label: '最大可浏览层级', type: 'number', min: 0, max: 8, hint: '本站 squaremap 为 3(该层级 1 方块 = 1 像素)' },
{ path: 'bigMap.rangeBlocks', label: '覆盖半径(方块)', type: 'number', min: 256, hint: '默认 20480,与服务器世界边界一致' },
{ path: 'bigMap.crawlOnStart', label: '启动时自动预爬取', type: 'checkbox' },
{ path: 'bigMap.concurrency', label: '瓦片下载并发数', type: 'number', min: 1, max: 16 }
]
}
],
grim: [
{
title: 'Grim 反作弊合规层',
fields: [
{ path: 'grim.enabled', label: '启用合规层', type: 'checkbox', hint: '仅在本地测试服才建议关闭' },
{ path: 'grim.maxRotationPerTickDeg', label: '每 tick 最大旋转(度)', type: 'number', step: 0.1, min: 1, max: 180, hint: 'mineflayer 物理限速约 8.6°/tick,实际取两者较小值' },
{ path: 'grim.attackReach', label: '攻击距离(格)', type: 'number', step: 0.1, min: 1, max: 6 },
{ path: 'grim.requireLineOfSight', label: '攻击前校验视线', type: 'checkbox' },
{ path: 'grim.respectAttackCooldown', label: '遵守攻击冷却', type: 'checkbox' },
{ path: 'grim.stopMovementWhileEating', label: '进食时停止移动', type: 'checkbox' },
{ path: 'grim.blockSprintJump', label: '禁止疾跑跳跃', type: 'checkbox', hint: '疾跑跳本身是原版合法动作,仅在实测被标记时开启' },
{ path: 'grim.blockConflictingActions', label: '拦截冲突动作组合', type: 'checkbox' },
{ path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' },
{ path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' },
{ path: 'grim.reportOnGroundFromWorld', label: '按世界数据修正 onGround', type: 'checkbox', hint: '服务端频繁下发位置包时,mineflayer 会持续上报「站地上却在空中」而被反作弊每 tick 拉回;开启后在发包前用脚下方块复核(实测本服必需)' },
{ path: 'grim.sendPlayerInput', label: '每 tick 补发 player_input', type: 'checkbox', hint: '1.21.2+ 移动预测依赖它;mineflayer 地面移动时完全不发,导致反作弊判定「无输入却位移」并把机器人每 tick 拉回(实测本服必需)' },
{ path: 'grim.sendTickEnd', label: '每 tick 补发 tick_end', type: 'checkbox', hint: '原版 1.21.2+ 客户端每 tick 末尾发送,反作弊用它判定 tick 边界' }
]
}
],
server: [
{
title: '服务器交互',
fields: [
{ path: 'server.claimCommand', label: '开垦模式指令', type: 'text', hint: '不带斜杠;留空表示服务器自动开启' },
{ path: 'server.loginCommand', label: '登录指令模板', type: 'text', hint: '用 {password} 占位,密码取自 .env 的 MC_BOTS / MC_PASSWORD' },
{ path: 'server.registerCommand', label: '注册指令模板', type: 'text', hint: '留空则不自动注册' },
{ path: 'server.authCommand', label: '账号绑定 / 二次验证模板', type: 'text', hint: '如简幻通的 auth {password};扫码类验证无法自动完成,留空即可(日志会提示人工验证一次)' },
{ path: 'server.preLoginCommands', label: '允许免登录发送的指令前缀', type: 'list', hint: '逗号分隔,默认 login,l,register,reg,auth;这些指令在登录验证前也放行,避免死锁' },
{ path: 'server.allowChatBeforeLogin', label: '登录前允许发公屏消息', type: 'checkbox', hint: '服务器明确要求验证时会自动放行(验证码常直接当聊天内容发出);此项用于无提示但必须先说话的服务器' },
{ path: 'server.loginConfirmTimeoutMs', label: '登录结果确认超时(毫秒)', type: 'number', min: 0, hint: '超时未收到登录提示则按已登录放行;0 表示禁用兜底' },
{ path: 'server.commandCooldownMs', label: '指令最小间隔(毫秒)', type: 'number', min: 0 },
{ path: 'server.autoReconnect', label: '断线自动重连', type: 'checkbox' },
{ path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 },
{ path: 'server.reconnectMaxDelayMs', label: '重连间隔上限(毫秒)', type: 'number', min: 1000, hint: '连续重连失败时按指数退避,最多退到这个间隔' }
]
}
]
};
// ==================== 全局状态 ====================
/** 当前选中的机器人 id */
let selectedBotId = '';
/** 最近一次状态负载 */
let lastStatus = null;
/** 机器人 id → 该机器人的设置快照 */
const botSettings = new Map();
// ==================== 登录 ====================
let appStarted = false;
function showLogin(message) {
$('loginOverlay').classList.remove('hidden');
if (message) $('loginError').textContent = message;
}
function hideLogin() {
$('loginOverlay').classList.add('hidden');
$('loginError').textContent = '';
}
async function checkSession() {
try {
const res = await fetch('/api/session', {
headers: TOKEN ? { 'X-Auth-Token': TOKEN } : {}
}).then((r) => r.json());
const data = res.data || {};
$('authBadge').textContent = data.required ? (data.authenticated ? '已登录' : '未登录') : '免登录';
$('authBadge').className = 'badge ' + (data.authenticated || !data.required ? 'badge-online' : 'badge-idle');
if (data.required && !data.authenticated) {
showLogin();
return false;
}
hideLogin();
return true;
} catch {
return true;
}
}
async function doLogin() {
const password = $('loginPassword').value;
if (!password) {
$('loginError').textContent = '请输入密码 / 令牌';
return;
}
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
}).then((r) => r.json());
if (!res.ok) {
$('loginError').textContent = res.message || '登录失败';
return;
}
setToken(password);
hideLogin();
$('authBadge').textContent = '已登录';
$('authBadge').className = 'badge badge-online';
if (appStarted) {
// 之前已初始化过(退出登录后再登录):只需恢复实时推送
wsEnabled = true;
connectWs();
} else {
await startApp();
}
toast('登录成功');
} catch (err) {
$('loginError').textContent = '登录失败:' + err.message;
}
}
function doLogout() {
setToken('');
wsEnabled = false;
$('authBadge').textContent = '未登录';
$('authBadge').className = 'badge badge-idle';
if (ws) { try { ws.close(); } catch { /* 忽略 */ } ws = null; }
showLogin('已退出登录');
}
// ==================== 配置读写 ====================
let currentConfig = null;
let activeTab = 'strategy';
function getByPath(obj, path) {
return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
}
function setByPath(obj, path, value) {
const keys = path.split('.');
let cursor = obj;
for (let i = 0; i < keys.length - 1; i++) {
if (cursor[keys[i]] == null || typeof cursor[keys[i]] !== 'object') cursor[keys[i]] = {};
cursor = cursor[keys[i]];
}
cursor[keys[keys.length - 1]] = value;
}
function renderConfig() {
const body = $('configBody');
body.innerHTML = '';
const groups = CONFIG_SCHEMA[activeTab] || [];
for (const group of groups) {
const title = document.createElement('div');
title.className = 'group-title';
title.textContent = group.title;
body.appendChild(title);
for (const field of group.fields) {
body.appendChild(buildField(field));
}
}
}
function buildField(field) {
const wrap = document.createElement('div');
wrap.className = 'field';
const label = document.createElement('label');
label.textContent = field.label;
wrap.appendChild(label);
const value = getByPath(currentConfig, field.path);
if (field.type === 'checkbox') {
const inline = document.createElement('div');
inline.className = 'field-inline';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = value === true;
input.dataset.path = field.path;
input.dataset.type = 'checkbox';
inline.appendChild(input);
wrap.appendChild(inline);
} else if (field.type === 'select') {
const select = document.createElement('select');
select.className = 'input';
select.dataset.path = field.path;
select.dataset.type = 'select';
for (const option of field.options) {
const opt = document.createElement('option');
opt.value = String(option.value);
opt.textContent = option.label;
if (String(value) === String(option.value)) opt.selected = true;
select.appendChild(opt);
}
wrap.appendChild(select);
} else {
const input = document.createElement('input');
input.className = 'input';
input.type = field.type === 'password' ? 'password' : (field.type === 'number' ? 'number' : 'text');
if (field.min !== undefined) input.min = field.min;
if (field.max !== undefined) input.max = field.max;
if (field.step !== undefined) input.step = field.step;
if (field.type === 'list') {
// 字符串数组用逗号分隔编辑,避免为几个短列表单独做一套增删 UI
input.value = Array.isArray(value) ? value.join(', ') : '';
} else {
input.value = value === undefined || value === null ? '' : String(value);
}
input.dataset.path = field.path;
input.dataset.type = field.type;
wrap.appendChild(input);
}
if (field.hint) {
const hint = document.createElement('div');
hint.className = 'hint';
hint.textContent = field.hint;
wrap.appendChild(hint);
}
return wrap;
}
function collectConfig() {
const result = JSON.parse(JSON.stringify(currentConfig || {}));
for (const input of $('configBody').querySelectorAll('[data-path]')) {
const path = input.dataset.path;
const type = input.dataset.type;
let value;
if (type === 'checkbox') value = input.checked;
else if (type === 'select') {
const raw = input.value;
value = /^-?\d+$/.test(raw) ? Number(raw) : raw;
} else if (type === 'number') value = input.value === '' ? 0 : Number(input.value);
else if (type === 'list') {
value = input.value.split(',').map((item) => item.trim()).filter(Boolean);
} else value = input.value;
setByPath(result, path, value);
}
return result;
}
async function loadConfig() {
try {
if ($('chkBotOverride').checked && selectedBotId) {
const res = await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/settings');
currentConfig = res.data.config;
} else {
const res = await api('/api/config');
currentConfig = res.data;
}
renderConfig();
} catch (err) {
toast('读取配置失败:' + err.message, true);
}
}
async function saveConfig() {
try {
const config = collectConfig();
if ($('chkBotOverride').checked && selectedBotId) {
const res = await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/settings', {
method: 'POST',
body: JSON.stringify({ config })
});
currentConfig = res.data.config;
toast('已保存为「' + selectedBotId + '」的专属配置');
} else {
const res = await api('/api/config', { method: 'POST', body: JSON.stringify(config) });
currentConfig = res.data;
toast('全局配置已保存并应用');
}
renderConfig();
} catch (err) {
toast('保存配置失败:' + err.message, true);
}
}
// ==================== 机器人列表 ====================
const BOT_STATE_TEXT = { online: '已连接', connecting: '连接中', offline: '已断开', idle: '未连接' };
/** 上次渲染机器人列表用的签名:状态每秒推送一次,签名不变就不重建 DOM */
let botListSignature = '';
function botSignature(bots) {
return bots.map((b) => [
b.id, b.state, b.enabled, b.source, b.task ? b.task.state : '', b.task ? b.task.completedClaims : '',
b.position ? Math.round(b.position.x) : '', b.position ? Math.round(b.position.z) : ''
].join(':')).join('|');
}
function renderBotList(status) {
const bots = status.bots || [];
$('botCount').textContent = String(bots.length);
const signature = botSignature(bots) + '#' + selectedBotId;
if (signature === botListSignature) return;
botListSignature = signature;
const list = $('botList');
list.innerHTML = '';
if (bots.length === 0) {
const empty = document.createElement('div');
empty.className = 'exp-empty';
empty.textContent = '未配置任何机器人:请在 .env 设置 MC_BOTS 或 MC_USERNAME / MC_PASSWORD';
list.appendChild(empty);
return;
}
for (const bot of bots) {
const item = document.createElement('div');
item.className = 'bot-item' + (bot.id === selectedBotId ? ' active' : '');
item.dataset.botId = bot.id;
const dot = document.createElement('span');
dot.className = 'dot ' + (bot.enabled === false ? 'disabled' : (bot.state || 'idle'));
item.appendChild(dot);
const info = document.createElement('div');
const name = document.createElement('div');
name.className = 'name';
name.textContent = bot.label && bot.label !== bot.username
? `${bot.label}(${bot.username})` : (bot.username || bot.id);
const sub = document.createElement('div');
sub.className = 'sub';
const pos = bot.position ? `${bot.position.x}, ${bot.position.z}` : '未知位置';
sub.textContent = `${BOT_STATE_TEXT[bot.state] || bot.state} · ${pos}`;
info.appendChild(name);
info.appendChild(sub);
item.appendChild(info);
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = (bot.task ? bot.task.state : 'IDLE') + ' · ' + (bot.task ? bot.task.completedClaims : 0);
if (bot.source !== 'env') {
// 控制台新增的账号可以在这里直接移除;.env 里的必须改 .env
const remove = document.createElement('button');
remove.className = 'remove';
remove.textContent = '×';
remove.title = '移除该玩家';
remove.addEventListener('click', (e) => {
e.stopPropagation();
removeBot(bot.id, bot.username);
});
meta.appendChild(remove);
} else {
meta.title = '该玩家在 .env 的 MC_BOTS 中配置,需修改 .env 后重启才能移除';
}
item.appendChild(meta);
item.addEventListener('click', () => selectBot(bot.id));
list.appendChild(item);
}
// 机器人选择下拉
const select = $('botSelect');
const previous = select.value;
select.innerHTML = '';
for (const bot of bots) {
const opt = document.createElement('option');
opt.value = bot.id;
opt.textContent = bot.username || bot.id;
select.appendChild(opt);
}
const desired = bots.some((b) => b.id === selectedBotId) ? selectedBotId
: (bots.some((b) => b.id === previous) ? previous : (status.defaultBotId || (bots[0] && bots[0].id) || ''));
select.value = desired;
if (desired && desired !== selectedBotId) selectBot(desired, true);
// 开拓日志的机器人过滤器
const expFilter = $('expFilterBot');
const expPrevious = expFilter.value;
expFilter.innerHTML = '';
for (const bot of bots) {
const opt = document.createElement('option');
opt.value = bot.id;
opt.textContent = bot.username || bot.id;
expFilter.appendChild(opt);
}
expFilter.value = expPrevious;
}
function selectBot(id, silent) {
if (!id) return;
const changed = id !== selectedBotId;
selectedBotId = id;
botListSignature = '';
scheduleSignature = '';
mapView.centered = false;
bigMapView.fitted = false;
$('botSelect').value = id;
$('consoleTarget').textContent = id;
for (const item of document.querySelectorAll('.bot-item')) {
item.classList.toggle('active', item.dataset.botId === id);
}
if (changed || !silent) {
loadConfig();
loadChat();
}
if (lastStatus) renderStatus(lastStatus);
}
// ==================== 状态渲染 ====================
function selectedBot(status) {
const bots = (status && status.bots) || [];
return bots.find((b) => b.id === selectedBotId) || bots[0] || null;
}
function renderStatus(status) {
lastStatus = status;
renderBotList(status);
const bot = selectedBot(status) || {};
const task = bot.task || {};
// ===== 顶栏徽标 =====
const connBadge = $('connBadge');
connBadge.textContent = BOT_STATE_TEXT[bot.state] || bot.state || '未知';
connBadge.className = 'badge ' + (bot.state === 'online' ? 'badge-online' : 'badge-offline');
const taskBadge = $('taskBadge');
taskBadge.textContent = task.state || 'IDLE';
taskBadge.className = 'badge ' + (task.running ? 'badge-running' : (task.state === 'COMPLETE' ? 'badge-online' : 'badge-idle'));
// ===== 连接卡片 =====
$('kServer').textContent = bot.host || '-';
$('kUsername').textContent = bot.username || '-';
$('kVersion').textContent = bot.version || '-';
$('kPos').textContent = bot.position
? `${bot.position.x} / ${bot.position.y} / ${bot.position.z}`
: '-';
$('kChunk').textContent = bot.chunk ? `${bot.chunk.x}, ${bot.chunk.z}` : '-';
$('kHealth').textContent = bot.health === null || bot.health === undefined
? '-'
: `${bot.health.toFixed(1)} / ${bot.food === null || bot.food === undefined ? '-' : bot.food}`;
$('kBotState').textContent = (bot.riding ? '乘船中 · ' : '') + (bot.inWater ? '水中' : '陆地');
const loginEl = $('kLogin');
loginEl.textContent = bot.loginFailed ? '登录被拒绝' : (bot.loggedIn ? '已通过' : '未通过');
loginEl.style.color = bot.loginFailed ? 'var(--red)' : (bot.loggedIn ? 'var(--green)' : 'var(--yellow)');
$('kReconnect').textContent = String(bot.reconnectAttempts || 0);
if (document.activeElement !== $('chkEnabled')) $('chkEnabled').checked = bot.enabled !== false;
if (document.activeElement !== $('chkAutoStart')) $('chkAutoStart').checked = bot.autoStart === true;
const canOperate = bot.canOperate === true;
$('consoleHint').textContent = canOperate
? '以 / 开头按游戏指令发送,否则按公屏消息发送。'
: '该机器人尚未通过游戏内登录验证,发送会被拒绝(服务器要求验证时会自动放行,用于发验证码)。';
$('consoleHint').style.color = canOperate ? 'var(--muted)' : 'var(--yellow)';
// ===== 任务卡片 =====
$('kTaskState').textContent = task.state || 'IDLE';
$('kTaskStatus').textContent = task.status || '-';
$('kTaskStateName').textContent = task.stateName || '-';
$('kTarget').textContent = task.target ? `${task.target.x}, ${task.target.z}` : '-';
$('kQueue').textContent = String(task.queuedTargets || 0);
$('kCompleted').textContent = String(task.completedClaims || 0);
$('kRetry').textContent = String(task.retryCount || 0);
$('kProgress').textContent = (task.lastProgress || 0) + '%';
$('kTimeout').textContent = Math.round((task.consecutiveTimeoutMs || 0) / 1000) + 's';
$('kClaimTime').textContent = task.withinClaimTime ? '时段内' : '时段外';
$('progressBar').style.width = (task.lastProgress || 0) + '%';
// ===== 数据源卡片 =====
const data = status.data || {};
$('kMarkersUrl').textContent = data.markersUrl || '-';
$('kLastFetch').textContent = data.lastSuccessfulFetchMillis ? fmtAgo(data.lastSuccessfulFetchMillis) : '从未';
$('kDataError').textContent = data.lastError || '无';
$('kDataError').style.color = data.lastError ? 'var(--red)' : '';
$('kStateCount').textContent = String(data.stateCount || 0);
$('kClaimed').textContent = String(data.claimedChunks || 0);
$('kSession').textContent = String(data.sessionClaims || 0);
$('kFlying').textContent = String(data.flyingChunks || 0);
$('kApi').textContent = data.apiEnabled
? `已启用(${data.apiTerritoryCount || 0} 国,最近成功 ${data.apiLastSuccessMillis ? fmtAgo(data.apiLastSuccessMillis) : '从未'})`
: '未启用';
if (data.apiError) $('kApi').textContent += ' · ' + data.apiError;
// ===== 开拓日志统计 =====
if (status.expansionLog) {
$('btnExportExp').title = `累计 ${status.expansionLog.total} 条,今日 ${status.expansionLog.today} 条`;
}
// ===== SimpCore API 状态(弹层标题栏一行概览)=====
if (status.data) {
updateSimpStatus(status.data);
}
// ===== 大地图爬取进度 =====
if (status.bigMap && status.bigMap.crawl) {
const crawl = status.bigMap.crawl;
const btn = $('btnCrawlAround');
btn.textContent = crawl.running ? `爬取中 ${crawl.done}/${crawl.total}` : '爬取周边';
btn.disabled = crawl.running === true;
}
// ===== 定时消息 =====
renderSchedules(bot.schedules || []);
// ===== Grim 卡片 =====
const grim = bot.grim;
if (grim) {
$('kGrimEnabled').textContent = grim.enabled ? '已启用' : '已关闭';
$('kGrimRot').textContent = `${grim.maxRotationPerTickDeg}°(实际 ${grim.effectiveStepDeg}°/s)`;
$('kGrimSettled').textContent = grim.rotationSettled ? '是' : '否';
$('kGrimReach').textContent = grim.attackReach + ' 格';
$('kGrimCooldown').textContent = grim.attackCooldownMs > 0 ? grim.attackCooldownMs + ' ms' : '就绪';
const packets = grim.protocolPackets;
if (packets) {
// 这两个补包是「机器人能不能走」的关键,直接显示发送计数最直观
const parts = [];
if (packets.playerInputEnabled) {
parts.push(packets.playerInputSupported
? `player_input ${packets.playerInputSent}`
: 'player_input 当前版本不支持');
}
if (packets.tickEndEnabled) parts.push(`tick_end ${packets.tickEndSent}`);
$('kGrimPackets').textContent = parts.length > 0 ? parts.join(' · ') : '已关闭';
$('kGrimPackets').style.color = parts.length > 0 ? 'var(--green)' : 'var(--yellow)';
}
$('kGrimBlocked').textContent = String(grim.blockedActions || 0);
$('kGrimAudit').textContent = grim.physicsOk ? '通过' : '异常';
$('kGrimAudit').style.color = grim.physicsOk ? 'var(--green)' : 'var(--red)';
const issues = $('grimIssues');
issues.innerHTML = '';
for (const issue of grim.issues || []) {
const li = document.createElement('li');
li.textContent = issue;
issues.appendChild(li);
}
}
drawMap();
}
// ==================== 定时消息 ====================
/** 上次渲染定时列表用的签名,避免每秒推送都重建 DOM */
let scheduleSignature = '';
/**
* 渲染当前机器人的定时发送列表。
*
* @param {Array