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();