feat: 1.7.0 落地 TODO 全部待办(地面修正 / 手动执行器 / 自救 / 回城 / 租约 / 通知)

按 TODO.md 的优先级把待办全部落地为代码,详见 CHANGELOG 的 [1.7.0]。

- 「走不动」攻关:地面状态修正(syncGroundToPhysics / trustSetbackOnGround)、
  出站包类型分布、备选执行器 manualMover(只借 A* 规划、自己按键执行)
- 原点自救分两轮并统计成败;回城前评估「原地是否更差」并记录前后坐标
- 新增目标区块租约表(targetLease)避免多机器人撞同一块
- 定时消息支持 cron 与「期望回复」确认;新增人工介入 Webhook 通知(notifier)
- 大地图后台预热与视野裁剪;控制台日志按帧批量写入、配置面板支持搜索

顺带修掉两个影响部署的细节:
- 部署脚本(deploy.js / ssh-helper.js)改为也能从 .env 读 SSH 凭据 ——
  在命令行传密码会留在 shell 历史与进程列表里,写进已被忽略的 .env 更安全;
  显式环境变量仍然优先。.env.example 补上占位符并说明这些只在本机用。
- package.json / package-lock.json 版本对齐到 1.7.0(此前停留在 1.1.0,
  与 CHANGELOG 的语义化版本声明脱节)。

测试:368 → 436 个用例,`npm test` 与 `npm run check` 全绿。
这个提交包含在:
lyizai
2026-09-28 22:46:39 +08:00
父节点 8d1b8d5122
当前提交 51664c55b6
共修改 31 个文件,包含 3535 行新增和 251 行删除
+200 -11
查看文件
@@ -160,6 +160,18 @@ const CONFIG_SCHEMA = {
{ path: 'regionCenterZ', label: '中心 Z(区块)', type: 'number' },
{ path: 'regionRadius', label: '半径(区块)', type: 'number', min: 1 }
]
},
{
title: '多机器人协调',
fields: [
{
path: 'coordination.targetLeaseTtlSeconds',
label: '目标区块租约时长(秒)',
type: 'number',
min: 30,
hint: '多个机器人同时扩地时用租约避免撞在同一区块上;持有者持续续租,掉线 / 卡死后租约到期自动释放'
}
]
}
],
timing: [
@@ -218,7 +230,12 @@ const CONFIG_SCHEMA = {
{ path: 'pathing.liquidCost', label: '液体格额外代价', type: 'number', min: 1 },
{ path: 'pathing.canOpenDoors', label: '允许开门', type: 'checkbox' },
{ path: 'pathing.maxSegmentBlocks', label: '单段寻路最长距离(格)', type: 'number', min: 32, hint: '寻路器只能规划已加载区块范围(本服约 130 格),长距离会自动拆段;此项只是单段上限' },
{ path: 'pathing.segmentMarginBlocks', label: '分段安全边距(格)', type: 'number', min: 0, hint: '分段时距已加载范围边缘留出的余量,避免踩在未加载边界上' }
{ path: 'pathing.segmentMarginBlocks', label: '分段安全边距(格)', type: 'number', min: 0, hint: '分段时距已加载范围边缘留出的余量,避免踩在未加载边界上' },
{ path: 'pathing.executor', label: '寻路执行器', type: 'select', options: [
{ value: 'pathfinder', label: 'pathfinder(默认)' },
{ value: 'manual', label: '手动执行(备选方案)' }
], hint: '手动执行:只用 A* 算路径,执行改为自己按键(与手动移动测试一致的时序);仅在实测确认寻路器执行层被服务端回弹时才切换' },
{ path: 'pathing.manualTimeoutMs', label: '手动执行超时(毫秒)', type: 'number', min: 10000, hint: '单段手动执行的最长时间,防止卡死时无限按键' }
]
}
],
@@ -282,7 +299,12 @@ const CONFIG_SCHEMA = {
{ path: 'bigMap.maxZoom', label: '最大可浏览层级', type: 'number', min: 0, max: 8, hint: '本站 squaremap 为 3(该层级 1 方块 = 1 像素)' },
{ path: 'bigMap.rangeBlocks', label: '覆盖半径(方块)', type: 'number', min: 256, hint: '默认 20480,与服务器世界边界一致' },
{ path: 'bigMap.crawlOnStart', label: '启动时自动预爬取', type: 'checkbox' },
{ path: 'bigMap.concurrency', label: '瓦片下载并发数', type: 'number', min: 1, max: 16 }
{ path: 'bigMap.concurrency', label: '瓦片下载并发数', type: 'number', min: 1, max: 16 },
{ path: 'bigMap.prewarm', label: '后台预热高层级瓦片', type: 'checkbox', hint: '机器人移动到新区域时低优先级拉取周边高层级瓦片,避免首次缩放时干等;已缓存的不重复下载' },
{ path: 'bigMap.prewarmRangeBlocks', label: '预热半径(方块)', type: 'number', min: 256 },
{ path: 'bigMap.prewarmLevels', label: '预热层级数', type: 'number', min: 0, max: 8, hint: '从「启动预爬取层级」往上数几层(受最大可浏览层级限制)' },
{ path: 'bigMap.prewarmMinMoveBlocks', label: '移动多少格后重新预热', type: 'number', min: 0 },
{ path: 'bigMap.prewarmIntervalMs', label: '预热最小间隔(毫秒)', type: 'number', min: 60000, hint: '即便没怎么移动,超过这个时间也会再预热一次' }
]
}
],
@@ -301,6 +323,9 @@ const CONFIG_SCHEMA = {
{ path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' },
{ path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' },
{ path: 'grim.reportOnGroundFromWorld', label: '按世界数据修正 onGround', type: 'checkbox', hint: '服务端频繁下发位置包时,mineflayer 会持续上报「站地上却在空中」而被反作弊每 tick 拉回;开启后在发包前用脚下方块复核(实测本服必需)' },
{ path: 'grim.syncGroundToPhysics', label: '物理引擎 onGround 对齐(方案 A)', type: 'checkbox', hint: '每个物理 tick 用世界数据复核脚下是否有方块,是则把物理引擎的 onGround 同步为 true,让移动走地面分支(TODO 问题 1 的疑似根因)' },
{ path: 'grim.trustSetbackOnGround', label: '不采纳例行同步的 onGround(方案 B)', type: 'checkbox', hint: '服务端下发的位置与本地几乎一致时,只同步坐标、不采纳它的「在空中」,避免物理引擎的地面状态被反复打断' },
{ path: 'grim.setbackSyncEpsilon', label: '方案 B 判定阈值(格)', type: 'number', step: 0.05, min: 0, max: 5, hint: '位置差不超过它时视为例行同步;超过则按真正的传送处理。先用默认值实测,再看状态卡里的「最近同步差」调整' },
{ path: 'grim.sendPlayerInput', label: '每 tick 补发 player_input', type: 'checkbox', hint: '1.21.2+ 移动预测依赖它;mineflayer 地面移动时完全不发,导致反作弊判定「无输入却位移」并把机器人每 tick 拉回(实测本服必需)' },
{ path: 'grim.sendTickEnd', label: '每 tick 补发 tick_end', type: 'checkbox', hint: '原版 1.21.2+ 客户端每 tick 末尾发送,反作弊用它判定 tick 边界' }
]
@@ -322,6 +347,15 @@ const CONFIG_SCHEMA = {
{ path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 },
{ path: 'server.reconnectMaxDelayMs', label: '重连间隔上限(毫秒)', type: 'number', min: 1000, hint: '连续重连失败时按指数退避,最多退到这个间隔' }
]
},
{
title: '通知提醒(Webhook)',
fields: [
{ path: 'notify.enabled', label: '启用通知', type: 'checkbox', hint: '需要人工介入时(等待验证码 / 原点自救失败)推送到飞书等自定义机器人,不用一直盯控制台' },
{ path: 'notify.webhookUrl', label: 'Webhook 地址', type: 'password', hint: '飞书 / Lark 自定义机器人的 Webhook;留空回退环境变量 NOTIFY_WEBHOOK_URL' },
{ path: 'notify.onVerification', label: '等待验证码时提醒', type: 'checkbox', hint: '验证码只有 120 秒时限,错过要等下次重登' },
{ path: 'notify.onOriginRescueFailed', label: '原点自救失败时提醒', type: 'checkbox', hint: '自救失败多半说明被切到了登录服,需要人工发验证码' }
]
}
]
};
@@ -442,11 +476,41 @@ function renderConfig() {
body.appendChild(buildField(field));
}
}
filterConfigFields($('configSearch') ? $('configSearch').value : '');
}
/**
* 按关键词过滤配置项(8 个标签页、字段很多,逐个找很费劲)。
*
* 只隐藏不删除:隐藏的输入仍然留在 DOM 里,保存时 `collectConfig` 依旧能读到它们的值。
*
* @param {string} query 搜索词(名称 / 字段名 / 说明,大小写不敏感)
*/
function filterConfigFields(query) {
const body = $('configBody');
if (!body) return;
const keyword = String(query || '').trim().toLowerCase();
let lastTitle = null;
let titleHasVisible = false;
for (const child of Array.from(body.children)) {
if (child.classList.contains('group-title')) {
if (lastTitle) lastTitle.classList.toggle('hidden', !titleHasVisible);
lastTitle = child;
titleHasVisible = false;
continue;
}
const match = !keyword || (child.dataset.search || '').includes(keyword);
child.classList.toggle('hidden', !match);
if (match) titleHasVisible = true;
}
if (lastTitle) lastTitle.classList.toggle('hidden', !titleHasVisible);
}
function buildField(field) {
const wrap = document.createElement('div');
wrap.className = 'field';
// 供配置搜索使用:名称 + 字段路径 + 说明一起匹配,输入「回城」「returnOnTimeout」都能找到
wrap.dataset.search = `${field.label} ${field.path} ${field.hint || ''}`.toLowerCase();
const label = document.createElement('label');
label.textContent = field.label;
wrap.appendChild(label);
@@ -751,6 +815,18 @@ function renderStatus(status) {
loginEl.title = '';
}
$('kReconnect').textContent = String(bot.reconnectAttempts || 0);
// 原点自救统计:成功率与最近一次结果(失败说明可能其实是登录服,需要人工发验证码)
const rescue = bot.originRescue || {};
const rescueEl = $('kOriginRescue');
if ((rescue.attempts || 0) > 0) {
const last = rescue.lastResult;
rescueEl.textContent = `成功 ${rescue.successes || 0}/${rescue.attempts}`
+ (last ? `(最近${last.success ? '成功' : '失败'},第 ${last.rounds} 轮)` : '');
rescueEl.style.color = last && last.success ? 'var(--green)' : 'var(--yellow)';
} else {
rescueEl.textContent = '未触发';
rescueEl.style.color = '';
}
// 上线 / 下线按钮:在线时是「下线」,离线时是「上线」
const offlineBtn = $('btnOffline');
@@ -789,6 +865,21 @@ function renderStatus(status) {
$('kRetry').textContent = String(task.retryCount || 0);
$('kProgress').textContent = (task.lastProgress || 0) + '%';
$('kTimeout').textContent = Math.round((task.consecutiveTimeoutMs || 0) / 1000) + 's';
// 最近一次回城把机器人从哪挪到了哪(回城是破坏性操作,必须能看出是否值得)
const returnEl = $('kLastReturn');
const lastReturn = task.lastReturn;
if (lastReturn && lastReturn.from && lastReturn.to) {
returnEl.textContent = `(${lastReturn.from.x}, ${lastReturn.from.z}) → `
+ `(${lastReturn.to.x}, ${lastReturn.to.z}),直线 ${lastReturn.distance} 格`;
returnEl.title = lastReturn.reason || '';
returnEl.style.color = lastReturn.distance >= 8 ? 'var(--green)' : 'var(--yellow)';
} else if ((task.returnSkippedNotStuck || 0) > 0) {
returnEl.textContent = `未回城(原地还能走,已放弃 ${task.returnSkippedNotStuck} 次)`;
returnEl.style.color = 'var(--green)';
} else {
returnEl.textContent = '-';
returnEl.style.color = '';
}
$('kClaimTime').textContent = task.withinClaimTime ? '时段内' : '时段外';
$('progressBar').style.width = (task.lastProgress || 0) + '%';
@@ -850,6 +941,13 @@ function renderStatus(status) {
$('kGrimPackets').style.color = parts.length > 0 ? 'var(--green)' : 'var(--yellow)';
}
$('kGrimBlocked').textContent = String(grim.blockedActions || 0);
// 地面状态修正(TODO 问题 1):方案 A 对齐次数 / 方案 B 保住次数 / 最近一次同步差
const stats = bot.packetStats || {};
const groundParts = [`物理对齐 ${stats.physicsGroundSync || 0}`, `保住 ${stats.setbackOnGroundKept || 0}`];
if (stats.lastSetbackDelta) groundParts.push(`最近同步差 ${stats.lastSetbackDelta.distance} 格`);
$('kGrimGround').textContent = groundParts.join(' · ');
$('kGrimGround').style.color = (stats.physicsGroundSync || stats.setbackOnGroundKept)
? 'var(--green)' : 'var(--yellow)';
$('kGrimAudit').textContent = grim.physicsOk ? '通过' : '异常';
$('kGrimAudit').style.color = grim.physicsOk ? 'var(--green)' : 'var(--red)';
const issues = $('grimIssues');
@@ -878,7 +976,10 @@ function renderSchedules(schedules) {
const list = $('scheduleList');
if (!list) return;
const signature = (schedules || [])
.map((t) => [t.id, t.enabled, t.content, t.intervalSeconds, t.nextInSeconds].join(':'))
.map((t) => [
t.id, t.enabled, t.content, t.mode, t.cron, t.intervalSeconds, t.nextInSeconds,
t.expectPattern, t.lastResult ? `${t.lastResult.ok ? 1 : 0}@${t.lastResult.at}` : ''
].join(':'))
.join('|');
if (signature === scheduleSignature) return;
scheduleSignature = signature;
@@ -887,7 +988,7 @@ function renderSchedules(schedules) {
if (!schedules || schedules.length === 0) {
const empty = document.createElement('div');
empty.className = 'schedule-empty';
empty.textContent = '暂无定时任务。点「+ 新增」可让机器人每隔一段时间重复发送同一条消息或指令。';
empty.textContent = '暂无定时任务。点「+ 新增」可让机器人每隔一段时间(或按 cron 表达式)重复发送同一条消息或指令。';
list.appendChild(empty);
return;
}
@@ -911,8 +1012,22 @@ function renderSchedules(schedules) {
content.appendChild(code);
const sub = document.createElement('div');
sub.className = 'sub';
sub.textContent = `${task.kind === 'command' ? '指令' : '公屏'} · 每 ${task.intervalSeconds} 秒`
+ (task.label ? ` · ${task.label}` : '');
// 调度描述:cron 任务显示表达式,间隔任务显示秒数;无效表达式会带出错误说明
const scheduleText = task.mode === 'cron'
? `cron ${task.cron}`
: `每 ${task.intervalSeconds} 秒`;
const parts = [task.kind === 'command' ? '指令' : '公屏', scheduleText];
if (task.label) parts.push(task.label);
// 发送结果确认:成功显示绿色对勾,超时未确认显示黄色感叹号
if (task.expectPattern) {
if (task.lastResult && task.lastResult.ok) parts.push('✓ 已确认');
else if (task.lastResult) parts.push('⚠ 未确认');
else parts.push('待确认');
}
if (task.cronError) parts.push(task.cronError);
sub.textContent = parts.join(' · ');
if (task.cronError) sub.style.color = 'var(--red)';
else if (task.lastResult && task.lastResult.ok === false) sub.style.color = 'var(--yellow)';
content.appendChild(sub);
item.appendChild(content);
@@ -949,14 +1064,18 @@ async function saveSchedules(schedules) {
}
}
/** 取当前列表的原始定义(去掉服务端附加的剩余秒数字段) */
/** 取当前列表的原始定义(去掉服务端附加的剩余秒数 / 发送结果等运行时字段) */
function currentSchedules() {
const bot = selectedBot(lastStatus || {});
return ((bot && bot.schedules) || []).map((t) => ({
id: t.id,
label: t.label,
content: t.content,
mode: t.mode === 'cron' ? 'cron' : 'interval',
cron: t.cron || '',
intervalSeconds: t.intervalSeconds,
expectPattern: t.expectPattern || '',
confirmTimeoutSeconds: t.confirmTimeoutSeconds,
enabled: t.enabled
}));
}
@@ -1629,9 +1748,46 @@ async function renderSimpV2(body, force) {
// ==================== 运行日志 ====================
const MAX_LOG_LINES = 800;
/**
* 待渲染的日志队列。
*
* 服务端是「一条日志一次推送」,而机器人卡住时日志会成串到达(例如「被服务器传送」
* 的爆发),逐条改 DOM + 逐条写 scrollTop 会让日志区明显卡顿(手机上尤其明显)。
* 这里把同一帧内到达的日志合并成一次 DOM 更新。
*/
let pendingLogs = [];
let logFlushScheduled = false;
function appendLog(entry) {
pendingLogs.push(entry);
// 后台标签页里 rAF 不触发,这里做个上限,避免队列无限增长
if (pendingLogs.length > MAX_LOG_LINES * 2) pendingLogs.splice(0, pendingLogs.length - MAX_LOG_LINES * 2);
scheduleLogFlush();
}
function scheduleLogFlush() {
if (logFlushScheduled) return;
logFlushScheduled = true;
requestAnimationFrame(() => {
logFlushScheduled = false;
flushLogs();
});
}
/** 把队列里的日志一次性写入 DOM(一次布局、一次滚动) */
function flushLogs() {
if (pendingLogs.length === 0) return;
const view = $('logView');
const fragment = document.createDocumentFragment();
for (const entry of pendingLogs) fragment.appendChild(buildLogLine(entry));
pendingLogs = [];
view.appendChild(fragment);
while (view.childNodes.length > MAX_LOG_LINES) view.removeChild(view.firstChild);
if ($('chkAutoScroll').checked) view.scrollTop = view.scrollHeight;
}
/** 构造一行日志的 DOM */
function buildLogLine(entry) {
const line = document.createElement('span');
line.className = 'log-line log-' + (entry.level || 'info');
const time = document.createElement('span');
@@ -1639,14 +1795,14 @@ function appendLog(entry) {
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;
return line;
}
function renderLogs(entries) {
pendingLogs = [];
$('logView').innerHTML = '';
for (const entry of entries) appendLog(entry);
for (const entry of entries) pendingLogs.push(entry);
flushLogs();
}
// ==================== 公屏聊天 ====================
@@ -1991,6 +2147,9 @@ function drawBigMap() {
ctx.lineWidth = 1 / px;
for (const state of bigMapView.markers.states) {
if (!state.path) continue;
// 按视野裁剪:全世界视角下邦国多边形很多,视野外的直接跳过(超大地图防掉帧)
if (state.bounds && (state.bounds.maxX < minBlockX || state.bounds.minX > maxBlockX
|| state.bounds.maxZ < minBlockZ || state.bounds.minZ > maxBlockZ)) continue;
ctx.fillStyle = hexToRgba(state.color, 0.26);
ctx.fill(state.path, 'evenodd');
ctx.strokeStyle = hexToRgba(state.color, 0.85);
@@ -2304,14 +2463,26 @@ function setBigMapMarkers(markers) {
for (const state of states) {
const path = new Path2D();
let has = false;
// 方块坐标包围盒:全世界视角下用来按视野裁剪多边形(视野外的不必绘制,避免掉帧)
let minX = Infinity;
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
for (const flat of state.polygons || []) {
if (flat.length < 6) continue;
path.moveTo(flat[0], flat[1]);
for (let i = 2; i < flat.length; i += 2) path.lineTo(flat[i], flat[i + 1]);
path.closePath();
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];
}
has = true;
}
state.path = has ? path : null;
state.bounds = Number.isFinite(minX) ? { minX, maxX, minZ, maxZ } : null;
}
bigMapView.markers = { world: markers.world, updatedAt: markers.updatedAt, states };
}
@@ -2495,11 +2666,21 @@ function bindEvents() {
toast('请填写要发送的内容', true);
return;
}
const mode = $('schedMode').value === 'cron' ? 'cron' : 'interval';
const cron = $('schedCron').value.trim();
if (mode === 'cron' && !cron) {
toast('cron 模式下必须填写表达式,例如 0 9 * * *', true);
return;
}
const next = currentSchedules();
next.push({
id: 'task-' + Date.now().toString(36),
content,
mode,
cron: mode === 'cron' ? cron : '',
intervalSeconds: Number($('schedInterval').value) || 300,
expectPattern: $('schedExpect').value.trim(),
confirmTimeoutSeconds: Number($('schedConfirm').value) || 10,
label: $('schedLabel').value.trim(),
enabled: true
});
@@ -2507,6 +2688,11 @@ function bindEvents() {
await saveSchedules(next);
});
// 调度方式切换:选 cron 时显示表达式输入框(固定间隔输入框保留,切回来不用重填)
$('schedMode').addEventListener('change', () => {
$('schedCronField').classList.toggle('hidden', $('schedMode').value !== 'cron');
});
$('btnRefreshMap').addEventListener('click', async () => {
try {
await api('/api/map/refresh', { method: 'POST' });
@@ -2555,6 +2741,9 @@ function bindEvents() {
renderConfig();
});
// 配置搜索:只隐藏不删除,保存时仍能读到隐藏字段的值
$('configSearch').addEventListener('input', (e) => filterConfigFields(e.target.value));
// ===== 地图视图切换 =====
$('mapTabs').addEventListener('click', (e) => {
const tab = e.target.closest('.tab');
+31 -3
查看文件
@@ -81,6 +81,7 @@
<dt>生命 / 饥饿</dt><dd id="kHealth">-</dd>
<dt>状态</dt><dd id="kBotState">-</dd>
<dt>登录验证</dt><dd id="kLogin">-</dd>
<dt>原点自救</dt><dd id="kOriginRescue">-</dd>
<dt>重连次数</dt><dd id="kReconnect">0</dd>
</dl>
<div class="row">
@@ -102,6 +103,7 @@
<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="kLastReturn">-</dd>
<dt>开垦时段</dt><dd id="kClaimTime">-</dd>
</dl>
<div class="progress"><div id="progressBar" class="progress-fill"></div></div>
@@ -119,20 +121,42 @@
<input id="schedContent" class="input" type="text" placeholder="/u restore confirm">
</div>
<div class="field-row">
<div class="field">
<label>调度方式</label>
<select id="schedMode" class="input">
<option value="interval">固定间隔</option>
<option value="cron">cron 表达式</option>
</select>
</div>
<div class="field">
<label>间隔(秒)</label>
<input id="schedInterval" class="input" type="number" min="5" value="300">
</div>
</div>
<div class="field hidden" id="schedCronField">
<label>cron 表达式(分 时 日 月 周,本地时间)</label>
<input id="schedCron" class="input" type="text" placeholder="0 9 * * *">
<div class="hint">例:<code>*/30 * * * *</code> 每 30 分钟 · <code>0 9 * * *</code> 每天 09:00 · <code>0 12 * * 1-5</code> 工作日 12:00</div>
</div>
<div class="field-row">
<div class="field">
<label>备注</label>
<input id="schedLabel" class="input" type="text" placeholder="恢复民心">
<label>期望回复(正则,可留空)</label>
<input id="schedExpect" class="input" type="text" placeholder="民心.*恢复">
</div>
<div class="field">
<label>确认超时(秒)</label>
<input id="schedConfirm" class="input" type="number" min="1" max="120" value="10">
</div>
</div>
<div class="field">
<label>备注</label>
<input id="schedLabel" class="input" type="text" placeholder="恢复民心">
</div>
<div class="row row-compact">
<button id="btnSaveSchedule" class="btn btn-sm btn-primary">保存</button>
<button id="btnCancelSchedule" class="btn btn-sm">取消</button>
</div>
<p class="hint">只会在机器人已连接且通过登录验证时发送;未就绪的周期会跳过,不会补发。</p>
<p class="hint">只会在机器人已连接且通过登录验证时发送;未就绪的周期会跳过,不会补发。填了「期望回复」时,发出后会等待服务器回复,超时未回复会在日志里给出警告。</p>
</div>
</div>
@@ -164,6 +188,7 @@
<dt>攻击距离</dt><dd id="kGrimReach">-</dd>
<dt>攻击冷却</dt><dd id="kGrimCooldown">-</dd>
<dt>协议补包</dt><dd id="kGrimPackets">-</dd>
<dt>地面修正</dt><dd id="kGrimGround">-</dd>
<dt>已拦下动作</dt><dd id="kGrimBlocked">0</dd>
<dt>物理自检</dt><dd id="kGrimAudit">-</dd>
</dl>
@@ -323,6 +348,9 @@
<button class="tab" data-tab="grim">Grim</button>
<button class="tab" data-tab="server">服务器</button>
</div>
<div class="field config-search">
<input id="configSearch" class="input" type="search" placeholder="搜索配置项(名称 / 字段名 / 说明)" autocomplete="off">
</div>
<div id="configBody" class="config-body"></div>
</div>
</section>
+11
查看文件
@@ -314,6 +314,17 @@ html, body {
.log-error { color: var(--red); }
.log-success { color: var(--green); }
/*
* 日志 / 公屏行很多时(移动端尤其明显):让浏览器跳过「视口外」行的渲染与布局。
* 相当于浏览器自带的虚拟列表 —— 不改变内容与滚动位置,只是不再为看不见的行做绘制。
* 不支持的浏览器会忽略这两行,行为与原来一致。
*/
.log-line,
.chat-line {
content-visibility: auto;
contain-intrinsic-size: auto 20px;
}
.chat-line { display: block; }
.chat-time { color: #55606f; margin-right: 6px; }
.chat-bot { color: var(--accent-2); margin-right: 4px; }
+36
查看文件
@@ -11,6 +11,13 @@ const { defaultConfig, normalizeConfig } = require('../config/expansionConfig');
/** 状态推送间隔(毫秒) */
const STATUS_PUSH_INTERVAL_MS = 1000;
/**
* 大地图预热检查间隔(毫秒)。
*
* 这里只是「低频问一句」,真正的节流(移动够远 / 距上次够久)由
* BigMapService.prewarmAround 自己判断,所以这个间隔可以设得很保守。
*/
const MAP_PREWARM_CHECK_INTERVAL_MS = 60000;
/** 登录接口路径(免令牌,否则无法登录) */
const LOGIN_PATH = '/api/login';
/** 会话探测接口路径(免令牌) */
@@ -52,6 +59,8 @@ class WebConsole {
this.expansionLog = deps.expansionLog || null;
this.chatLog = deps.chatLog || null;
this.bigMap = deps.bigMap || null;
/** @type {import('../core/notifier').Notifier|null} 人工介入提醒(状态只暴露域名,不含完整地址) */
this.notifier = deps.notifier || null;
this.getConfig = deps.getConfig;
this.applyConfig = deps.applyConfig;
this.exportDir = opts.exportDir || path.join(__dirname, '..', '..', 'data');
@@ -120,6 +129,10 @@ class WebConsole {
}, STATUS_PUSH_INTERVAL_MS);
if (this._pushTimer.unref) this._pushTimer.unref();
// 大地图后台预热:低频检查机器人位置,移动够远就顺带拉取周边高层级瓦片(低优先级)
this._mapPrewarmTimer = setInterval(() => this._maybePrewarmMap(), MAP_PREWARM_CHECK_INTERVAL_MS);
if (this._mapPrewarmTimer.unref) this._mapPrewarmTimer.unref();
// 新日志 / 新公屏消息 / 新开拓记录实时推送
this.logger.onEntry = (entry) => this._broadcast('log', entry);
if (this.chatLog) this.chatLog.onEntry = (entry) => this._broadcast('chat', entry);
@@ -148,6 +161,7 @@ class WebConsole {
/** 停止服务 */
async stop() {
if (this._pushTimer) clearInterval(this._pushTimer);
if (this._mapPrewarmTimer) clearInterval(this._mapPrewarmTimer);
this.logger.onEntry = null;
if (this.chatLog) this.chatLog.onEntry = null;
if (this.expansionLog) this.expansionLog.onEntry = null;
@@ -949,6 +963,8 @@ class WebConsole {
expansionLog: this.expansionLog ? this.expansionLog.stats() : null,
chatLog: this.chatLog ? this.chatLog.snapshot() : null,
bigMap: this.bigMap ? this.bigMapPayload() : null,
/** 通知提醒状态:是否启用 / 是否配好地址 / 已发与失败条数 */
notify: this.notifier ? this.notifier.snapshot() : null,
serverTime: Date.now(),
claimTime: {
start: this.getConfig().claimStartHour,
@@ -1053,6 +1069,26 @@ class WebConsole {
});
}
/**
* 大地图后台预热(低频调用):以「第一个有坐标的机器人」为中心,
* 交给 BigMapService 判断是否值得预热(移动够远 / 距上次够久)。
*
* 预热是低优先级任务:失败只记一条日志,不影响任何业务功能。
*/
_maybePrewarmMap() {
if (!this.bigMap) return;
let target = null;
try {
target = this.pool.positions().find((bot) => bot.position) || null;
} catch {
return;
}
if (!target) return;
this.bigMap.prewarmAround(target.position.x, target.position.z).catch((err) => {
this.logger.log('大地图预热失败:' + (err && err.message ? err.message : String(err)), 'warn');
});
}
/** @returns {object} 大地图上的领土标记(多边形 + 王城) */
bigMapMarkersPayload() {
const model = this.dataService.getModel();