feat: 用 Node.js + mineflayer 重构 AutoStore,UI 改为网页控制台并适配 Grim 反作弊

由 Fabric 客户端模组 AutoStore v1.3.1 重构为独立 Node.js 机器人,无需游戏客户端。

核心实现:
- 领土数据:Dynmap markers.json 拉取解析(奇偶填充识别国中飞地)、SimpCore 开放 API 第四数据源、
  带缓存击穿的时间戳请求与内容未变化跳过解析
- 扩地规划:四种策略(方形外扩 / 区域 S 形扫描 / 玩家周围 / 绕国一圈)逐行对照移植,
  接壤判定只算己方领土,飞地优先填补
- 扩地状态机:规划→寻路→等待开垦→成功/失败→下一目标→完成,含超时重试、
  往边上走两步自救、连续超时回城、耐久保护回城
- 生存辅助:自动进食、自动攻击(豁免中立生物)、自动游泳防溺水、自动用船(默认关闭)
- 寻路:Baritone → mineflayer-pathfinder,高度偏好用包装 Goal 等价移植

Grim 反作弊合规层(新增,核心差异点):
- 旋转不超前:接管 bot.look 按 ≤8.6°/tick 推进朝向,消除 mineflayer
  「本地朝向瞬间跳变、数据包滞后补发」导致的 Prediction 分歧
- 攻击三重校验:朝向已同步 + 距离 ≤3.0 格 + 视线可达 + 攻击冷却 + 非骑乘/进食
- 动作互斥:拦下疾跑+潜行、疾跑+使用物品等原版不可能出现的组合
- 物理参数只读自检 + 寻路约束(禁挖/禁放/禁跑酷/禁搭塔/限下落/绕开水域)

网页控制台(新增):
- 状态面板、Canvas 领土地图(缩放/平移/图层)、分组配置表单、实时日志流
- REST + WebSocket 接口,支持 WEB_TOKEN 令牌鉴权

修复:
- containsFuzzy 只取固定长度前缀的末行编辑距离,导致完全相同的子串也匹配不上
- 占领去重依赖 Set.add 返回值(JS 中恒为真),导致同一区块被重复计数
- 非法枚举值不再钳制到最近边界,改为回退默认值

测试:154 个用例,npm test 全绿;npm run check 语法自检通过。
这个提交包含在:
lyizai
2026-09-27 17:17:41 +08:00
当前提交 a12a24c716
共修改 44 个文件,包含 12225 行新增和 0 行删除
+853
查看文件
@@ -0,0 +1,853 @@
'use strict';
/**
* AutoStore Bot Web 控制台前端逻辑。
*
* 三块职责:
* 1. 通过 WebSocket 接收实时状态与日志,渲染左侧状态面板;
* 2. 根据配置 schema 动态生成右侧配置表单,读写 `/api/config`;
* 3. 用 Canvas 绘制领土地图(支持缩放 / 平移 / 图层开关)。
*/
// ==================== 工具 ====================
const $ = (id) => document.getElementById(id);
/** 取 URL 上的 token 参数,后续所有请求都带上 */
const TOKEN = new URLSearchParams(location.search).get('token') || '';
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.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);
}
function fmtTime(ts) {
if (!ts) return '-';
const d = new Date(ts);
const pad = (n) => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(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);
}
// ==================== 配置 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' }
]
}
],
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 }
]
}
],
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' }
]
}
],
server: [
{
title: '服务器交互',
fields: [
{ path: 'server.claimCommand', label: '开垦模式指令', type: 'text', hint: '不带斜杠;留空表示服务器自动开启' },
{ path: 'server.loginCommand', label: '登录指令模板', type: 'text', hint: '用 {password} 占位,密码取自 .env 的 MC_PASSWORD' },
{ path: 'server.registerCommand', label: '注册指令模板', type: 'text', hint: '留空则不自动注册' },
{ path: 'server.commandCooldownMs', label: '指令最小间隔(毫秒)', type: 'number', min: 0 },
{ path: 'server.autoReconnect', label: '断线自动重连', type: 'checkbox' },
{ path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 }
]
}
]
};
// ==================== 配置读写 ====================
let currentConfig = null;
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;
}
let activeTab = 'strategy';
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;
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 value = input.value;
setByPath(result, path, value);
}
return result;
}
async function loadConfig() {
try {
const res = await api('/api/config');
currentConfig = res.data;
renderConfig();
} catch (err) {
toast('读取配置失败:' + err.message, true);
}
}
async function saveConfig() {
try {
const config = collectConfig();
const res = await api('/api/config', { method: 'POST', body: JSON.stringify(config) });
currentConfig = res.data;
renderConfig();
toast('配置已保存并应用');
} catch (err) {
toast('保存配置失败:' + err.message, true);
}
}
// ==================== 状态渲染 ====================
let lastStatus = null;
function renderStatus(status) {
lastStatus = status;
const bot = status.bot || {};
const task = status.task || {};
const data = status.data || {};
// ===== 顶栏徽标 =====
const connBadge = $('connBadge');
const stateText = { online: '已连接', connecting: '连接中', offline: '已断开', idle: '未连接' };
connBadge.textContent = stateText[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.loggedIn ? '已登录 · ' : '') + (bot.riding ? '乘船中 · ' : '') + (bot.inWater ? '水中' : '陆地');
$('kReconnect').textContent = String(bot.reconnectAttempts || 0);
// ===== 任务卡片 =====
$('kTaskState').textContent = task.state || 'IDLE';
$('kTaskStatus').textContent = task.status || '-';
$('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) + '%';
// ===== 数据源卡片 =====
$('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;
// ===== Grim 卡片 =====
const grim = bot.grim;
if (grim) {
$('kGrimEnabled').textContent = grim.enabled ? '已启用' : '已关闭';
$('kGrimRot').textContent = `${grim.maxRotationPerTickDeg}°(实际 ${grim.effectiveStepDeg}°/tick)`;
$('kGrimSettled').textContent = grim.rotationSettled ? '是' : '否';
$('kGrimReach').textContent = grim.attackReach + ' 格';
$('kGrimCooldown').textContent = grim.attackCooldownMs > 0 ? grim.attackCooldownMs + ' ms' : '就绪';
$('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);
}
}
}
// ==================== 日志 ====================
const MAX_LOG_LINES = 800;
function appendLog(entry) {
const view = $('logView');
const line = document.createElement('span');
line.className = 'log-line log-' + (entry.level || 'info');
const time = document.createElement('span');
time.className = 'log-time';
time.textContent = '[' + entry.time + ']';
line.appendChild(time);
line.appendChild(document.createTextNode(entry.message));
view.appendChild(line);
while (view.childNodes.length > MAX_LOG_LINES) view.removeChild(view.firstChild);
if ($('chkAutoScroll').checked) view.scrollTop = view.scrollHeight;
}
function renderLogs(entries) {
$('logView').innerHTML = '';
for (const entry of entries) appendLog(entry);
}
// ==================== 地图 ====================
const mapView = {
centerX: 0,
centerZ: 0,
scale: 2,
dragging: false,
lastX: 0,
lastY: 0,
data: null,
/** 是否已经按玩家位置做过首次居中 */
centered: false
};
function initMap() {
const canvas = $('mapCanvas');
const ctx = canvas.getContext('2d');
const resize = () => {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
drawMap();
};
window.addEventListener('resize', resize);
canvas.addEventListener('mousedown', (e) => {
mapView.dragging = true;
mapView.lastX = e.clientX;
mapView.lastY = e.clientY;
});
window.addEventListener('mouseup', () => { mapView.dragging = false; });
window.addEventListener('mousemove', (e) => {
if (!mapView.dragging) return;
const dx = e.clientX - mapView.lastX;
const dy = e.clientY - mapView.lastY;
mapView.lastX = e.clientX;
mapView.lastY = e.clientY;
mapView.centerX -= dx / mapView.scale;
mapView.centerZ -= dy / mapView.scale;
drawMap();
});
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
mapView.scale = Math.min(24, Math.max(0.15, mapView.scale * factor));
drawMap();
}, { passive: false });
setTimeout(resize, 0);
window.addEventListener('resize', resize);
}
function chunkToScreen(cx, cz, canvas) {
const rect = canvas.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
const scale = mapView.scale;
return {
x: w / 2 + (cx - mapView.centerX) * scale,
y: h / 2 + (cz - mapView.centerZ) * scale,
scale
};
}
function drawMap() {
const canvas = $('mapCanvas');
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#05070a';
ctx.fillRect(0, 0, w, h);
const data = mapView.data;
const scale = mapView.scale;
const cell = Math.max(1, scale);
const drawChunks = (flat, color) => {
ctx.fillStyle = color;
for (let i = 0; i < flat.length; i += 2) {
const p = chunkToScreen(flat[i], flat[i + 1], canvas);
if (p.x < -cell || p.y < -cell || p.x > w + cell || p.y > h + cell) continue;
ctx.fillRect(p.x, p.y, cell, cell);
}
};
// 网格
if ($('chkGrid').checked && scale >= 1.2) {
ctx.strokeStyle = 'rgba(255,255,255,0.045)';
ctx.lineWidth = 1;
const halfX = w / 2 / scale;
const halfZ = h / 2 / scale;
const startX = Math.floor(mapView.centerX - halfX);
const endX = Math.ceil(mapView.centerX + halfX);
const startZ = Math.floor(mapView.centerZ - halfZ);
const endZ = Math.ceil(mapView.centerZ + halfZ);
ctx.beginPath();
for (let x = startX; x <= endX; x++) {
const sx = Math.round(chunkToScreen(x, 0, canvas).x) + 0.5;
ctx.moveTo(sx, 0); ctx.lineTo(sx, h);
}
for (let z = startZ; z <= endZ; z++) {
const sy = Math.round(chunkToScreen(0, z, canvas).y) + 0.5;
ctx.moveTo(0, sy); ctx.lineTo(w, sy);
}
ctx.stroke();
}
if (!data) {
ctx.fillStyle = '#48505e';
ctx.font = '13px "Microsoft YaHei", sans-serif';
ctx.fillText('暂无地图数据,等待 markers.json 拉取…', 16, 24);
return;
}
// 全部邦国(按需加载)
if ($('chkFull').checked && data.otherChunks) {
for (const state of data.otherChunks) {
drawChunks(state.chunks, hexToRgba(state.color, 0.55));
}
}
drawChunks(data.self || [], 'rgba(47,129,247,0.85)');
drawChunks(data.session || [], 'rgba(63,185,80,0.95)');
if ($('chkFlying').checked) drawChunks(data.flying || [], 'rgba(248,81,73,0.95)');
// 王城
for (const state of data.states || []) {
if (!state.capital) continue;
const p = chunkToScreen(state.capital.x, state.capital.z, canvas);
if (p.x < -20 || p.y < -20 || p.x > w + 20 || p.y > h + 20) continue;
ctx.fillStyle = '#bc8cff';
ctx.beginPath();
ctx.arc(p.x + scale / 2, p.y + scale / 2, Math.max(3, scale * 0.7), 0, Math.PI * 2);
ctx.fill();
if (scale >= 1) {
ctx.fillStyle = 'rgba(188,140,255,0.85)';
ctx.font = '11px "Microsoft YaHei", sans-serif';
ctx.fillText(state.name, p.x + 6, p.y + 4);
}
}
// 当前目标
if (data.target) {
const p = chunkToScreen(data.target.x, data.target.z, canvas);
ctx.strokeStyle = '#d29922';
ctx.lineWidth = 2;
const size = Math.max(8, scale * 2);
ctx.strokeRect(p.x - size / 2 + scale / 2, p.y - size / 2 + scale / 2, size, size);
ctx.beginPath();
ctx.moveTo(p.x - size, p.y + scale / 2);
ctx.lineTo(p.x + size, p.y + scale / 2);
ctx.moveTo(p.x + scale / 2, p.y - size);
ctx.lineTo(p.x + scale / 2, p.y + size);
ctx.stroke();
}
// 机器人位置
if (data.playerChunk) {
const p = chunkToScreen(data.playerChunk.x, data.playerChunk.z, canvas);
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(p.x + scale / 2, p.y + scale / 2, Math.max(3, scale * 0.6), 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#0d1117';
ctx.lineWidth = 1.5;
ctx.stroke();
}
$('mapInfo').textContent = `缩放 ${scale.toFixed(2)} px/区块 · 中心 ${Math.round(mapView.centerX)}, ${Math.round(mapView.centerZ)}`;
}
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})`;
}
let mapLoading = false;
async function loadMap() {
if (mapLoading) return;
mapLoading = true;
try {
const full = $('chkFull').checked ? '1' : '0';
const res = await api('/api/map?full=' + full);
mapView.data = res.data;
if (!mapView.centered && res.data.playerChunk) {
mapView.centerX = res.data.playerChunk.x;
mapView.centerZ = res.data.playerChunk.z;
mapView.centered = true;
}
drawMap();
} catch (err) {
toast('读取地图失败:' + err.message, true);
} finally {
mapLoading = false;
}
}
// ==================== WebSocket ====================
let ws = null;
let wsRetry = 0;
function connectWs() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const query = TOKEN ? '?token=' + encodeURIComponent(TOKEN) : '';
ws = new WebSocket(`${proto}://${location.host}/ws${query}`);
ws.onopen = () => { wsRetry = 0; };
ws.onmessage = (event) => {
let payload;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
if (payload.type === 'status') renderStatus(payload.data);
else if (payload.type === 'log') appendLog(payload.data);
else if (payload.type === 'logs') renderLogs(payload.data);
};
ws.onclose = () => {
wsRetry++;
const delay = Math.min(15000, 1000 * wsRetry);
setTimeout(connectWs, delay);
};
ws.onerror = () => { try { ws.close(); } catch { /* 忽略 */ } };
}
// ==================== 事件绑定 ====================
function bindEvents() {
$('btnStart').addEventListener('click', async () => {
try {
const res = await api('/api/task/start', { method: 'POST', body: JSON.stringify(collectConfig()) });
toast(res.message || '任务已启动');
renderStatus(res.data);
loadMap();
} catch (err) {
toast('启动失败:' + err.message, true);
}
});
$('btnStop').addEventListener('click', async () => {
try {
await api('/api/task/stop', { method: 'POST' });
toast('已请求停止');
} catch (err) {
toast('停止失败:' + err.message, true);
}
});
$('btnReconnect').addEventListener('click', async () => {
try {
await api('/api/bot/reconnect', { method: 'POST' });
toast('正在重连');
} catch (err) {
toast('重连失败:' + err.message, true);
}
});
$('btnRefreshMap').addEventListener('click', async () => {
try {
await api('/api/map/refresh', { method: 'POST' });
toast('已触发地图刷新');
} catch (err) {
toast(err.message, true);
}
});
$('btnFetchAll').addEventListener('click', async () => {
toast('正在拉取全服领土(可能较慢)…');
try {
const res = await api('/api/api/fetch-all', { method: 'POST' });
toast(res.message);
loadMap();
} catch (err) {
toast('拉取失败:' + err.message, true);
}
});
$('btnSaveConfig').addEventListener('click', saveConfig);
$('btnReloadConfig').addEventListener('click', loadConfig);
$('configTabs').addEventListener('click', (e) => {
const tab = e.target.closest('.tab');
if (!tab) return;
activeTab = tab.dataset.tab;
for (const el of $('configTabs').querySelectorAll('.tab')) {
el.classList.toggle('active', el === tab);
}
renderConfig();
});
$('chkFull').addEventListener('change', loadMap);
$('chkFlying').addEventListener('change', drawMap);
$('chkGrid').addEventListener('change', drawMap);
$('btnMapCenter').addEventListener('click', () => {
if (lastStatus && lastStatus.task && lastStatus.task.playerChunk) {
mapView.centerX = lastStatus.task.playerChunk.x;
mapView.centerZ = lastStatus.task.playerChunk.z;
drawMap();
}
});
$('btnMapFit').addEventListener('click', () => {
const data = mapView.data;
if (!data) return;
let minX = Infinity; let maxX = -Infinity; let minZ = Infinity; let maxZ = -Infinity;
const absorb = (flat) => {
for (let i = 0; i < flat.length; i += 2) {
if (flat[i] < minX) minX = flat[i];
if (flat[i] > maxX) maxX = flat[i];
if (flat[i + 1] < minZ) minZ = flat[i + 1];
if (flat[i + 1] > maxZ) maxZ = flat[i + 1];
}
};
absorb(data.self || []);
absorb(data.session || []);
if (!Number.isFinite(minX)) {
for (const state of data.states || []) {
if (!state.bounds) continue;
minX = Math.min(minX, state.bounds.minX);
maxX = Math.max(maxX, state.bounds.maxX);
minZ = Math.min(minZ, state.bounds.minZ);
maxZ = Math.max(maxZ, state.bounds.maxZ);
}
}
if (!Number.isFinite(minX)) return;
mapView.centerX = (minX + maxX) / 2;
mapView.centerZ = (minZ + maxZ) / 2;
const canvas = $('mapCanvas');
const rect = canvas.getBoundingClientRect();
const scaleX = rect.width / Math.max(1, maxX - minX + 2);
const scaleZ = rect.height / Math.max(1, maxZ - minZ + 2);
mapView.scale = Math.min(24, Math.max(0.15, Math.min(scaleX, scaleZ)));
drawMap();
});
$('btnClearLog').addEventListener('click', () => { $('logView').innerHTML = ''; });
$('btnExportLog').addEventListener('click', async () => {
try {
const res = await api('/api/logs/export', { method: 'POST' });
toast('已导出到 ' + res.data.file);
} catch (err) {
toast('导出失败:' + err.message, true);
}
});
$('btnSendCmd').addEventListener('click', sendCommand);
$('cmdInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendCommand();
});
for (const btn of document.querySelectorAll('[data-cmd]')) {
btn.addEventListener('click', () => {
$('cmdInput').value = btn.dataset.cmd;
sendCommand();
});
}
async function sendCommand() {
const command = $('cmdInput').value.trim();
if (!command) return;
try {
await api('/api/command', { method: 'POST', body: JSON.stringify({ command }) });
toast('已发送:' + command);
} catch (err) {
toast('发送失败:' + err.message, true);
}
}
}
// ==================== 启动 ====================
async function main() {
initMap();
bindEvents();
await loadConfig();
await loadMap();
connectWs();
// 地图数据每 20 秒自动刷新一次(服务端 markers 刷新间隔通常为 30 秒)
setInterval(loadMap, 20000);
// 相对时间("x 秒前")需要定期重绘
setInterval(() => { if (lastStatus) renderStatus(lastStatus); }, 5000);
}
main();
+172
查看文件
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AutoStore Bot 控制台</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo">AS</span>
<div>
<h1>AutoStore Bot</h1>
<p>Minecraft 自动扩地机器人 · mineflayer · Grim 合规</p>
</div>
</div>
<div class="topbar-right">
<span id="connBadge" class="badge badge-offline">未连接</span>
<span id="taskBadge" class="badge badge-idle">空闲</span>
<button id="btnStart" class="btn btn-primary">开始扩地</button>
<button id="btnStop" class="btn btn-danger">停止</button>
<button id="btnReconnect" class="btn">重连</button>
</div>
</header>
<main class="layout">
<!-- ==================== 左栏:状态面板 ==================== -->
<section class="column col-left">
<div class="card">
<h2>连接</h2>
<dl class="kv">
<dt>服务器</dt><dd id="kServer">-</dd>
<dt>玩家名</dt><dd id="kUsername">-</dd>
<dt>版本</dt><dd id="kVersion">-</dd>
<dt>坐标</dt><dd id="kPos">-</dd>
<dt>所在区块</dt><dd id="kChunk">-</dd>
<dt>生命 / 饥饿</dt><dd id="kHealth">-</dd>
<dt>状态</dt><dd id="kBotState">-</dd>
<dt>重连次数</dt><dd id="kReconnect">0</dd>
</dl>
</div>
<div class="card">
<h2>任务</h2>
<dl class="kv">
<dt>状态机</dt><dd id="kTaskState">IDLE</dd>
<dt>描述</dt><dd id="kTaskStatus">未启动</dd>
<dt>当前目标</dt><dd id="kTarget">-</dd>
<dt>队列</dt><dd id="kQueue">0</dd>
<dt>已完成</dt><dd id="kCompleted">0</dd>
<dt>重试</dt><dd id="kRetry">0</dd>
<dt>开垦进度</dt><dd id="kProgress">0%</dd>
<dt>连续寻路超时</dt><dd id="kTimeout">0s</dd>
<dt>开垦时段</dt><dd id="kClaimTime">-</dd>
</dl>
<div class="progress"><div id="progressBar" class="progress-fill"></div></div>
</div>
<div class="card">
<h2>领土数据源</h2>
<dl class="kv">
<dt>markers.json</dt><dd id="kMarkersUrl" class="mono small">-</dd>
<dt>最近成功</dt><dd id="kLastFetch">-</dd>
<dt>错误</dt><dd id="kDataError">-</dd>
<dt>邦国数</dt><dd id="kStateCount">0</dd>
<dt>已占领区块</dt><dd id="kClaimed">0</dd>
<dt>会话占领</dt><dd id="kSession">0</dd>
<dt>己方飞地</dt><dd id="kFlying">0</dd>
<dt>SimpCore API</dt><dd id="kApi">未启用</dd>
</dl>
<div class="row">
<button id="btnRefreshMap" class="btn btn-sm">刷新地图</button>
<button id="btnFetchAll" class="btn btn-sm">拉取全服领土</button>
</div>
</div>
<div class="card">
<h2>Grim 合规</h2>
<dl class="kv">
<dt>合规层</dt><dd id="kGrimEnabled">-</dd>
<dt>旋转限速</dt><dd id="kGrimRot">-</dd>
<dt>朝向已同步</dt><dd id="kGrimSettled">-</dd>
<dt>攻击距离</dt><dd id="kGrimReach">-</dd>
<dt>攻击冷却</dt><dd id="kGrimCooldown">-</dd>
<dt>已拦下动作</dt><dd id="kGrimBlocked">0</dd>
<dt>物理自检</dt><dd id="kGrimAudit">-</dd>
</dl>
<ul id="grimIssues" class="issues"></ul>
</div>
</section>
<!-- ==================== 中栏:地图 ==================== -->
<section class="column col-center">
<div class="card card-map">
<div class="card-head">
<h2>领土地图</h2>
<div class="row">
<label class="chk"><input type="checkbox" id="chkFull" checked> 显示全部邦国</label>
<label class="chk"><input type="checkbox" id="chkFlying" checked> 标出飞地</label>
<label class="chk"><input type="checkbox" id="chkGrid" checked> 网格</label>
<button id="btnMapCenter" class="btn btn-sm">回到玩家</button>
<button id="btnMapFit" class="btn btn-sm">全图</button>
</div>
</div>
<canvas id="mapCanvas"></canvas>
<div class="map-legend">
<span><i class="sw sw-self"></i>己方领土</span>
<span><i class="sw sw-session"></i>本次占领</span>
<span><i class="sw sw-flying"></i>飞地(疏漏)</span>
<span><i class="sw sw-target"></i>当前目标</span>
<span><i class="sw sw-player"></i>机器人</span>
<span><i class="sw sw-capital"></i>王城</span>
<span id="mapInfo" class="mono small"></span>
</div>
</div>
<div class="card card-log">
<div class="card-head">
<h2>运行日志</h2>
<div class="row">
<label class="chk"><input type="checkbox" id="chkAutoScroll" checked> 自动滚动</label>
<button id="btnClearLog" class="btn btn-sm">清屏</button>
<button id="btnExportLog" class="btn btn-sm">导出日志</button>
</div>
</div>
<div id="logView" class="log-view"></div>
</div>
</section>
<!-- ==================== 右栏:配置 ==================== -->
<section class="column col-right">
<div class="card card-config">
<div class="card-head">
<h2>配置</h2>
<div class="row">
<button id="btnSaveConfig" class="btn btn-sm btn-primary">保存配置</button>
<button id="btnReloadConfig" class="btn btn-sm">重新载入</button>
</div>
</div>
<div class="tabs" id="configTabs">
<button class="tab active" data-tab="strategy">策略</button>
<button class="tab" data-tab="timing">超时</button>
<button class="tab" data-tab="pathing">寻路</button>
<button class="tab" data-tab="survival">生存</button>
<button class="tab" data-tab="data">数据</button>
<button class="tab" data-tab="grim">Grim</button>
<button class="tab" data-tab="server">服务器</button>
</div>
<div id="configBody" class="config-body"></div>
</div>
<div class="card">
<h2>手动指令</h2>
<div class="row">
<input id="cmdInput" class="input" type="text" placeholder="u claimmode(不带斜杠)">
<button id="btnSendCmd" class="btn btn-sm">发送</button>
</div>
<div class="row">
<button class="btn btn-sm" data-cmd="u return">/u return</button>
<button class="btn btn-sm" data-cmd="u claimmode">/u claimmode</button>
<button class="btn btn-sm" data-cmd="spawn">/spawn</button>
<button class="btn btn-sm" data-cmd="list">/list</button>
</div>
</div>
</section>
</main>
<div id="toast" class="toast"></div>
<script src="app.js"></script>
</body>
</html>
+239
查看文件
@@ -0,0 +1,239 @@
:root {
--bg: #0d1117;
--panel: #161b22;
--panel-2: #1c2330;
--border: #30363d;
--text: #c9d1d9;
--muted: #8b949e;
--accent: #e94560;
--accent-2: #2f81f7;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--purple: #bc8cff;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
font-size: 14px;
}
.mono { font-family: Consolas, "Cascadia Mono", monospace; }
.small { font-size: 12px; }
/* ==================== 顶栏 ==================== */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 18px;
background: linear-gradient(90deg, #161b22, #1b2230);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.brand { display: flex; align-items: center; gap: 12px; }
.logo {
width: 38px; height: 38px;
display: grid; place-items: center;
border-radius: 9px;
background: var(--accent);
color: #fff; font-weight: 700; letter-spacing: 1px;
}
.brand h1 { font-size: 16px; margin: 0; }
.brand p { font-size: 12px; margin: 2px 0 0; color: var(--muted); }
.topbar-right { display: flex; align-items: center; gap: 8px; }
.badge {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid var(--border);
background: var(--panel-2);
}
.badge-online { color: var(--green); border-color: #1f6f34; }
.badge-offline { color: var(--muted); }
.badge-running { color: var(--accent-2); border-color: #1f4f8f; }
.badge-idle { color: var(--muted); }
.badge-error { color: var(--red); border-color: #8f2b26; }
.btn {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
cursor: pointer;
font-size: 13px;
transition: background .15s, border-color .15s;
}
.btn:hover { background: #232b39; border-color: #48505e; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: #d63851; border-color: #d63851; }
.btn-danger { background: #3a1d22; border-color: #7a2b34; color: #ff8b98; }
.btn-danger:hover { background: #4a2329; }
.btn-sm { padding: 4px 9px; font-size: 12px; }
/* ==================== 布局 ==================== */
.layout {
display: grid;
grid-template-columns: 300px minmax(420px, 1fr) 360px;
gap: 12px;
padding: 12px;
align-items: start;
}
.column { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
}
.card h2 {
font-size: 13px;
margin: 0 0 8px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .6px;
}
.card-head {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; margin-bottom: 8px;
}
.card-head h2 { margin: 0; }
.row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
/* ==================== 键值表 ==================== */
.kv {
display: grid;
grid-template-columns: 88px 1fr;
gap: 4px 8px;
margin: 0;
font-size: 13px;
}
.kv dt { color: var(--muted); }
.kv dd { margin: 0; word-break: break-all; }
.progress {
margin-top: 10px; height: 6px; border-radius: 3px;
background: #0b0f14; overflow: hidden;
}
.progress-fill {
height: 100%; width: 0%;
background: linear-gradient(90deg, var(--accent-2), var(--green));
transition: width .25s;
}
.issues { margin: 8px 0 0; padding-left: 18px; color: var(--yellow); font-size: 12px; }
.issues li { margin-bottom: 3px; }
/* ==================== 地图 ==================== */
.card-map { display: flex; flex-direction: column; }
#mapCanvas {
width: 100%;
height: 380px;
background: #05070a;
border: 1px solid var(--border);
border-radius: 8px;
display: block;
cursor: grab;
}
#mapCanvas:active { cursor: grabbing; }
.map-legend {
display: flex; gap: 14px; flex-wrap: wrap;
margin-top: 8px; font-size: 12px; color: var(--muted);
align-items: center;
}
.sw {
display: inline-block; width: 10px; height: 10px;
border-radius: 2px; margin-right: 4px; vertical-align: -1px;
}
.sw-self { background: #2f81f7; }
.sw-session { background: #3fb950; }
.sw-flying { background: #f85149; }
.sw-target { background: #d29922; }
.sw-player { background: #ffffff; }
.sw-capital { background: #bc8cff; }
.chk { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); cursor: pointer; }
/* ==================== 日志 ==================== */
.card-log { display: flex; flex-direction: column; }
.log-view {
height: 260px;
overflow-y: auto;
background: #05070a;
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
font-family: Consolas, "Cascadia Mono", monospace;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-all;
}
.log-line { display: block; }
.log-time { color: #586069; margin-right: 6px; }
.log-info { color: var(--text); }
.log-warn { color: var(--yellow); }
.log-error { color: var(--red); }
.log-success { color: var(--green); }
/* ==================== 配置 ==================== */
.card-config { display: flex; flex-direction: column; }
.tabs { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
.tab {
padding: 4px 10px; font-size: 12px; cursor: pointer;
background: var(--panel-2); border: 1px solid var(--border);
border-radius: 6px; color: var(--muted);
}
.tab.active { color: #fff; background: var(--accent-2); border-color: var(--accent-2); }
.config-body { max-height: 520px; overflow-y: auto; }
.field { margin-bottom: 9px; }
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 3px; }
.field .hint { font-size: 11px; color: #5d6672; margin-top: 2px; }
.input, select.input {
width: 100%; padding: 5px 8px;
background: #0b0f14; border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 13px;
outline: none;
}
.input:focus { border-color: var(--accent-2); }
.field-inline { display: flex; align-items: center; gap: 8px; }
.field-inline input[type=checkbox] { width: 15px; height: 15px; }
.group-title {
font-size: 12px; color: var(--accent-2);
margin: 12px 0 6px; padding-bottom: 4px;
border-bottom: 1px dashed var(--border);
}
.group-title:first-child { margin-top: 0; }
/* ==================== 提示 ==================== */
.toast {
position: fixed; right: 18px; bottom: 18px;
background: var(--panel-2); border: 1px solid var(--border);
border-left: 3px solid var(--accent-2);
padding: 10px 14px; border-radius: 8px;
font-size: 13px; max-width: 380px;
opacity: 0; transform: translateY(8px);
transition: opacity .2s, transform .2s;
pointer-events: none;
z-index: 100;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.error { border-left-color: var(--red); }
@media (max-width: 1280px) {
.layout { grid-template-columns: 1fr; }
.config-body { max-height: none; }
}
+367
查看文件
@@ -0,0 +1,367 @@
'use strict';
const http = require('http');
const path = require('path');
const express = require('express');
const { WebSocketServer } = require('ws');
const chunkPos = require('../core/chunkPos');
const { defaultConfig, normalizeConfig, copyConfig } = require('../config/expansionConfig');
/** 状态推送间隔(毫秒) */
const STATUS_PUSH_INTERVAL_MS = 1000;
/**
* Web 控制台服务:Express 提供静态页面与 REST 接口,WebSocket 推送实时状态与日志。
*
* 安全约定:
* - 默认只监听 127.0.0.1;
* - 配置 `WEB_TOKEN` 后所有 `/api` 请求与 WebSocket 握手都必须带正确令牌;
* - 令牌比较使用定时安全比较,避免时序侧信道。
*/
class WebConsole {
/**
* @param {object} deps
* @param {import('../bot/botManager').BotManager} deps.botManager
* @param {import('../controller/expansionController').ExpansionController} deps.controller
* @param {import('../core/territoryDataService').TerritoryDataService} deps.dataService
* @param {import('../util/logger').Logger} deps.logger
* @param {() => object} deps.getConfig
* @param {(config: object) => object} deps.applyConfig
* @param {{host?: string, port?: number, token?: string, exportDir?: string}} [options]
*/
constructor(deps, options) {
const opts = options || {};
this.botManager = deps.botManager;
this.controller = deps.controller;
this.dataService = deps.dataService;
this.logger = deps.logger;
this.getConfig = deps.getConfig;
this.applyConfig = deps.applyConfig;
this.exportDir = opts.exportDir || path.join(__dirname, '..', '..', 'data');
this.host = opts.host || '127.0.0.1';
this.port = opts.port === undefined ? 3081 : opts.port;
this.token = opts.token || '';
this.app = express();
this.server = null;
this.wss = null;
this._pushTimer = null;
/** @type {Set<import('ws').WebSocket>} */
this.clients = new Set();
this._setupRoutes();
}
/** 启动 HTTP + WebSocket 服务 */
start() {
this.server = http.createServer(this.app);
this.wss = new WebSocketServer({ noServer: true });
this.server.on('upgrade', (request, socket, head) => {
if (!this._authorized(request)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
this.wss.handleUpgrade(request, socket, head, (ws) => {
this.wss.emit('connection', ws, request);
});
});
this.wss.on('connection', (ws) => {
this.clients.add(ws);
ws.on('close', () => this.clients.delete(ws));
ws.on('error', () => this.clients.delete(ws));
this._send(ws, { type: 'status', data: this.statusPayload() });
this._send(ws, { type: 'logs', data: this.logger.list() });
});
this._pushTimer = setInterval(() => {
if (this.clients.size === 0) return;
const payload = JSON.stringify({ type: 'status', data: this.statusPayload() });
for (const ws of this.clients) {
if (ws.readyState === 1) ws.send(payload);
}
}, STATUS_PUSH_INTERVAL_MS);
if (this._pushTimer.unref) this._pushTimer.unref();
// 新日志实时推送
this.logger.onEntry = (entry) => {
const payload = JSON.stringify({ type: 'log', data: entry });
for (const ws of this.clients) {
if (ws.readyState === 1) ws.send(payload);
}
};
return new Promise((resolve) => {
this.server.listen(this.port, this.host, () => {
const address = this.server.address();
this.actualPort = address && typeof address === 'object' ? address.port : this.port;
resolve(this.url());
});
});
}
/** @returns {string} 控制台访问地址(带令牌,便于直接点击打开) */
url() {
const tokenSuffix = this.token ? `/?token=${encodeURIComponent(this.token)}` : '/';
return this.baseUrl() + tokenSuffix;
}
/** @returns {string} 控制台根地址(不含令牌),供 API 调用方拼接路径 */
baseUrl() {
return `http://${this.host}:${this.actualPort || this.port}`;
}
/** 停止服务 */
async stop() {
if (this._pushTimer) clearInterval(this._pushTimer);
this.logger.onEntry = null;
for (const ws of this.clients) {
try {
ws.close();
} catch {
// 忽略
}
}
this.clients.clear();
if (this.wss) await new Promise((resolve) => this.wss.close(resolve));
if (this.server) await new Promise((resolve) => this.server.close(resolve));
}
// ==================== 路由 ====================
_setupRoutes() {
const app = this.app;
app.disable('x-powered-by');
app.use(express.json({ limit: '1mb' }));
app.use((req, res, next) => {
if (!this._authorized(req)) {
res.status(401).json({ ok: false, message: '未授权:请在 URL 上带 ?token= 或提供 X-Auth-Token 头' });
return;
}
next();
});
app.use(express.static(path.join(__dirname, 'public')));
app.get('/api/status', (req, res) => {
res.json({ ok: true, data: this.statusPayload() });
});
app.get('/api/config', (req, res) => {
res.json({ ok: true, data: this.getConfig() });
});
app.post('/api/config', (req, res) => {
try {
const merged = normalizeConfig(req.body, this.getConfig());
const applied = this.applyConfig(merged);
res.json({ ok: true, data: applied });
} catch (err) {
res.status(400).json({ ok: false, message: err.message });
}
});
app.get('/api/config/defaults', (req, res) => {
res.json({ ok: true, data: defaultConfig() });
});
app.get('/api/logs', (req, res) => {
res.json({ ok: true, data: this.logger.list() });
});
app.post('/api/logs/export', (req, res) => {
const file = path.join(this.exportDir, `autostore-debug-${Date.now()}.log`);
const result = this.controller.exportLogs(file);
if (result) res.json({ ok: true, data: { file: result } });
else res.status(500).json({ ok: false, message: '导出失败' });
});
app.get('/api/map', (req, res) => {
try {
res.json({ ok: true, data: this.mapPayload(req.query.full === '1') });
} catch (err) {
res.status(500).json({ ok: false, message: err.message });
}
});
app.post('/api/task/start', async (req, res) => {
try {
const config = req.body && Object.keys(req.body).length > 0
? normalizeConfig(req.body, this.getConfig())
: this.getConfig();
const ok = await this.controller.start(config);
res.json({ ok, data: this.statusPayload(), message: ok ? '任务已启动' : this.controller.status });
} catch (err) {
res.status(500).json({ ok: false, message: err.message });
}
});
app.post('/api/task/stop', (req, res) => {
this.controller.stop();
res.json({ ok: true, data: this.statusPayload() });
});
app.post('/api/map/refresh', (req, res) => {
this.dataService.refreshAsync();
res.json({ ok: true, message: '已触发地图刷新' });
});
app.post('/api/api/fetch-all', async (req, res) => {
const result = await this.dataService.fetchAllTerritoryOnce();
res.json({ ok: result.ok, message: result.message });
});
app.post('/api/command', (req, res) => {
const command = req.body && req.body.command;
if (!command || typeof command !== 'string') {
res.status(400).json({ ok: false, message: '缺少 command 字段' });
return;
}
this.botManager.sendCommand(command.replace(/^\//, ''));
res.json({ ok: true, message: '已发送:' + command });
});
app.post('/api/bot/reconnect', (req, res) => {
this.botManager.disconnect();
setTimeout(() => this.botManager.connect(), 500);
res.json({ ok: true, message: '正在重连' });
});
}
/**
* 校验请求授权。
* @param {import('http').IncomingMessage} req
* @returns {boolean}
*/
_authorized(req) {
if (!this.token) return true;
let provided = '';
const header = req.headers['x-auth-token'];
if (typeof header === 'string') provided = header;
if (!provided && req.url) {
const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?') + 1) : '';
const params = new URLSearchParams(query);
provided = params.get('token') || '';
}
return timingSafeEqual(provided, this.token);
}
// ==================== 数据组装 ====================
/** @returns {object} 完整状态负载 */
statusPayload() {
return {
bot: this.botManager.snapshot(),
task: this.controller.snapshot(),
data: this.dataService.snapshot(),
serverTime: Date.now(),
claimTime: {
start: this.getConfig().claimStartHour,
end: this.getConfig().claimEndHour,
enforce: this.getConfig().enforceClaimTime
}
};
}
/**
* 组装地图数据。
*
* 区块一律编码为扁平的 `[x1, z1, x2, z2, ...]` 数组,减少 JSON 体积。
*
* @param {boolean} full 是否包含全部邦国的区块(体积大,按需请求)
* @returns {object}
*/
mapPayload(full) {
const model = this.dataService.getModel();
const selfName = model.selfStateNameValue();
const self = model.selfState();
const result = {
selfState: selfName,
self: self ? flattenChunks(self.chunks) : [],
session: flattenChunks(model.sessionClaimsSet()),
flying: flattenChunks(model.selfFlyingChunks()),
target: this.controller.target,
playerChunk: this.controller.playerChunk,
states: [],
full
};
for (const state of model.statesIter()) {
const bounds = boundsOf(state.chunks);
result.states.push({
name: state.name,
color: '#' + (state.color & 0xffffff).toString(16).padStart(6, '0'),
capital: state.capital,
count: state.chunks.size,
bounds
});
}
result.states.sort((a, b) => b.count - a.count);
if (full) {
result.otherChunks = [];
for (const state of model.statesIter()) {
if (state.name === selfName) continue;
const flat = flattenChunks(state.chunks);
result.otherChunks.push({ name: state.name, color: '#' + (state.color & 0xffffff).toString(16).padStart(6, '0'), chunks: flat });
}
}
return result;
}
_send(ws, payload) {
if (ws.readyState === 1) ws.send(JSON.stringify(payload));
}
}
/**
* 把区块键集合压平成 `[x1, z1, x2, z2, ...]`。
* @param {Set<number>} keys
* @returns {number[]}
*/
function flattenChunks(keys) {
const out = new Array(keys.size * 2);
let i = 0;
for (const k of keys) {
const pos = chunkPos.decode(k);
out[i++] = pos.x;
out[i++] = pos.z;
}
return out;
}
/**
* 计算区块集合的外接矩形。
* @param {Set<number>} keys
* @returns {{minX: number, maxX: number, minZ: number, maxZ: number}|null}
*/
function boundsOf(keys) {
if (keys.size === 0) return null;
let minX = Infinity;
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
for (const k of keys) {
const pos = chunkPos.decode(k);
if (pos.x < minX) minX = pos.x;
if (pos.x > maxX) maxX = pos.x;
if (pos.z < minZ) minZ = pos.z;
if (pos.z > maxZ) maxZ = pos.z;
}
return { minX, maxX, minZ, maxZ };
}
/**
* 定时安全字符串比较,避免令牌比较的时序侧信道。
* @param {string} a
* @param {string} b
* @returns {boolean}
*/
function timingSafeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
module.exports = { WebConsole, flattenChunks, boundsOf, timingSafeEqual, STATUS_PUSH_INTERVAL_MS };