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 语法自检通过。
这个提交包含在:
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
在新工单中引用
屏蔽一个用户