This commit is contained in:
cancerai committed 2026-10-21 13:54:25 +08:00
1 parent 234e769f4d
commit e6a0aa94de
10 files changed
+872 -583

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
// 共享可达性 BFS 核心:solve.mjs 与 reach-diag.mjs 共用
// 用真实物理常量模拟走/落/跳(含二段跳时机、空中转向、松键短跳)
export const TILE = 32, PW = 11, PH = 21;
export const GRAV = 0.4, MAXFALL = 9, JUMP = 8.5, DJUMP = 7.6, HSPD = 3;
export function setupGlobals() {
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
return handlers;
}
export function makeSim(L) {
// 水:近似为可站立
for (let y = 0; y < L.h; y++)
for (let x = 0; x < L.w; x++)
if (L.tiles[y][x] === 6) L.tiles[y][x] = 1;
// 移动平台:整条路径当桥(乐观近似)
const br = [];
for (const m of L.movers) {
const xs = m.path.map(p => p.x).concat([m.x]);
const ys = m.path.map(p => p.y).concat([m.y]);
const x0 = Math.min(...xs), x1 = Math.max(...xs) + m.w;
const y0 = Math.min(...ys), y1 = Math.max(...ys) + m.h;
br.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
}
function solidAt(x, y, w, h, down, prevBottom) {
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
const t = L.tileAt(tx, ty);
if (t === 7) { if (down && prevBottom !== undefined && prevBottom <= ty * TILE + 6) return true; continue; }
if (L.isSolidTile(t, tx, ty)) return true;
}
for (const b of br) if (x < b.x + b.w && x + w > b.x && y < b.y + b.h && y + h > b.y) return true;
return false;
}
function canStand(fx, fy) {
if (fx < 0 || fx + PW > L.pxW) return false;
if (solidAt(fx, fy - PH, PW, PH, false)) return false;
if (!solidAt(fx, fy - PH + 1, PW, PH, true, fy - 1)) return false;
if (L.deadlyRect(fx, fy - PH, PW, PH)) return false;
return true;
}
const overlap = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
function jump(fx, fy, vx0, djump, flip, rel, targets) {
let x = fx, y = fy - PH, vy = -JUMP, vx = vx0, t = 0, used = false;
while (t++ < 240) {
if (djump && !used && t === djump) { vy = -DJUMP; used = true; }
if (flip && t === flip) vx = -vx;
if (rel && t === rel && vy < -3) vy = -3;
vy = Math.min(vy + GRAV, MAXFALL);
// 真实行为(player.js):本帧被墙挡住只是没推动,按住方向键下一帧 vx 仍为 ±3。
// 绝不能把 vx 永久清零,否则"贴着墙上升、越过台面边缘后再横移"这条经典路线会丢失。
if (vx !== 0) {
const nx = x + vx;
if (!solidAt(nx, y, PW, PH, false)) x = nx;
}
const prevBottom = y + PH;
let rem = Math.abs(vy), dy = Math.sign(vy), landed = false;
while (rem > 0.0001) {
const m = Math.min(1, rem), ny = y + dy * m;
if (!solidAt(x, ny, PW, PH, dy > 0, prevBottom)) { y = ny; rem -= m; }
else { if (dy > 0) landed = true; vy = 0; break; }
}
if (L.deadlyRect(x, y, PW, PH)) return null;
if (targets) {
const box = { x, y, w: PW, h: PH };
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
}
if (y > L.pxH + 120 || y < -240) return null;
if (landed) return { fx: x, fy: y + PH };
}
return null;
}
function fall(fx, fy, dir, targets) {
let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0;
if (solidAt(x, y, PW, PH, false)) return null;
while (t++ < 400) {
vy = Math.min(vy + GRAV, MAXFALL);
const prevBottom = y + PH;
let rem = Math.abs(vy), landed = false;
while (rem > 0.0001) {
const m = Math.min(1, rem), ny = y + m;
if (!solidAt(x, ny, PW, PH, true, prevBottom)) { y = ny; rem -= m; }
else { landed = true; break; }
}
if (L.deadlyRect(x, y, PW, PH)) return null;
if (targets) {
const box = { x, y, w: PW, h: PH };
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
}
if (y > L.pxH + 120) return null;
if (landed) return { fx: x, fy: y + PH };
}
return null;
}
return { solidAt, canStand, jump, fall, overlap };
}
/** BFS 可达性:targets=[{rect,type,...}],onState 回调每个新状态 */
export const DEF_DJ = [0, 6, 12, 18, 24, 30];
export const DEF_REL = [0, 4, 8, 14];
export function reach(L, { DJSET = DEF_DJ, RELSET = DEF_REL, targets = [], onState = null, maxStates = 40000 } = {}) {
const sim = makeSim(L);
let sx = L.spawn.x, sy = L.spawn.y + PH, start = null;
for (let k = 0; k < 400; k++) {
if (sim.canStand(sx, sy + k)) { start = { fx: sx, fy: sy + k }; break; }
if (sy + k > L.pxH) break;
}
if (!start) return { err: '出生点下方找不到落脚点', states: 0, targets };
const key = s => `${Math.round(s.fx / 6)},${Math.round(s.fy / 6)}`;
const seen = new Set([key(start)]);
const q = [start];
let n = 0;
while (q.length && n < maxStates) {
const s = q.shift(); n++;
const box = { x: s.fx, y: s.fy - PH, w: PW, h: PH };
for (const tg of targets) if (!tg.hit && sim.overlap(box, tg.rect)) tg.hit = true;
const push = r => {
if (!r || !sim.canStand(r.fx, r.fy)) return;
const k = key(r);
if (seen.has(k)) return;
seen.add(k); q.push(r);
if (onState) onState(r);
};
for (const d of [-HSPD, HSPD]) push(sim.canStand(s.fx + d, s.fy) ? { fx: s.fx + d, fy: s.fy } : null);
for (const d of [-1, 1]) push(sim.fall(s.fx, s.fy, d, targets));
for (const vx of [-HSPD, 0, HSPD]) for (const dj of DJSET) for (const rel of RELSET) {
if (dj && rel) continue;
push(sim.jump(s.fx, s.fy, vx, dj, 0, rel, targets));
if (vx !== 0) push(sim.jump(s.fx, s.fy, vx, dj, 22, rel, targets));
}
}
return { states: seen.size, targets };
}
export function makeTargets(L) {
const targets = [];
for (const e of L.entities) {
if (e.type === 'goal' || e.type === 'save' || e.type === 'apple')
targets.push({ type: e.type, idx: e.idx, no: e.no, rect: { x: e.x, y: e.y, w: e.w, h: e.h }, hit: false });
}
return targets;
}
+20 -99
View File
@@ -1,110 +1,31 @@
// 诊断:打印可达站立点的最大 x(按行),找出断连位置
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
// 诊断:打印可达站立点范围(按行),找出断连位置
// 用法: node reach-diag.mjs <levelId>
import { setupGlobals, reach, TILE } from './lib-reach.mjs';
setupGlobals();
const { LEVELS } = await import('../js/levels/index.js');
const { Level } = await import('../js/level.js');
const TILE = 32, PW = 14, PH = 18;
const GRAV = 0.4, MAXFALL = 9, JUMP = 8.5, DJUMP = 7.6, HSPD = 3;
function solidAt(L, br, x, y, w, h, down, prevBottom) {
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
const t = L.tileAt(tx, ty);
if (t === 7) { if (down && prevBottom !== undefined && prevBottom <= ty * TILE + 6) return true; continue; }
if (L.isSolidTile(t, tx, ty)) return true;
}
for (const b of br) if (x < b.x + b.w && x + w > b.x && y < b.y + b.h && y + h > b.y) return true;
return false;
}
function canStand(L, br, fx, fy) {
if (fx < 0 || fx + PW > L.pxW) return false;
if (solidAt(L, br, fx, fy - PH, PW, PH, false)) return false;
if (!solidAt(L, br, fx, fy - PH + 1, PW, PH, true, fy - 1)) return false;
if (L.deadlyRect(fx, fy - PH, PW, PH)) return false;
return true;
}
function jump(L, br, fx, fy, vx0, djump, flip) {
let x = fx, y = fy - PH, vy = -JUMP, vx = vx0, t = 0, used = false;
while (t++ < 240) {
if (djump && !used && t === djump) { vy = -DJUMP; used = true; }
if (flip && t === flip) vx = -vx;
vy = Math.min(vy + GRAV, MAXFALL);
if (vx !== 0) { const nx = x + vx; if (!solidAt(L, br, nx, y, PW, PH, false)) x = nx; else vx = 0; }
const prevBottom = y + PH;
let rem = Math.abs(vy), dy = Math.sign(vy), landed = false;
while (rem > 0.0001) {
const m = Math.min(1, rem); const ny = y + dy * m;
if (!solidAt(L, br, x, ny, PW, PH, dy > 0, prevBottom)) { y = ny; rem -= m; }
else { if (dy > 0) landed = true; vy = 0; break; }
}
if (L.deadlyRect(x, y, PW, PH)) return null;
if (y > L.pxH + 120 || y < -240) return null;
if (landed) return { fx: x, fy: y + PH };
}
return null;
}
function fall(L, br, fx, fy, dir) {
let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0;
if (solidAt(L, br, x, y, PW, PH, false)) return null;
while (t++ < 400) {
vy = Math.min(vy + GRAV, MAXFALL);
let rem = Math.abs(vy), landed = false;
while (rem > 0.0001) {
const m = Math.min(1, rem); const ny = y + m;
if (!solidAt(L, br, x, ny, PW, PH, true, y + PH)) { y = ny; rem -= m; }
else { landed = true; break; }
}
if (L.deadlyRect(x, y, PW, PH)) return null;
if (y > L.pxH + 120) return null;
if (landed) return { fx: x, fy: y + PH };
}
return null;
}
const id = process.argv[2] || 'j6';
const d = LEVELS.find(l => l.id === id);
const L = new Level(d);
const br = [];
for (let y = 0; y < L.h; y++) for (let x = 0; x < L.w; x++) if (L.tiles[y][x] === 6) L.tiles[y][x] = 1;
for (const m of L.movers) {
const xs = m.path.map(p => p.x).concat([m.x]), ys = m.path.map(p => p.y).concat([m.y]);
const x0 = Math.min(...xs), x1 = Math.max(...xs) + m.w, y0 = Math.min(...ys), y1 = Math.max(...ys) + m.h;
br.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
}
let sx = L.spawn.x, sy = L.spawn.y + PH, start = null;
for (let k = 0; k < 400; k++) { if (canStand(L, br, sx, sy + k)) { start = { fx: sx, fy: sy + k }; break; } if (sy + k > L.pxH) break; }
const key = s => `${Math.round(s.fx / 6)},${Math.round(s.fy / 6)}`;
const seen = new Set([key(start)]); const q = [start]; let n = 0;
const byRow = new Map();
const note = s => {
const r = Math.round(s.fy / TILE);
const cur = byRow.get(r) || { max: -1, min: 1e9 };
byRow.set(r, { max: Math.max(cur.max, s.fx), min: Math.min(cur.min, s.fx) });
};
note(start);
while (q.length && n < 40000) {
const s = q.shift(); n++;
for (const d2 of [-HSPD, HSPD]) {
const nx = s.fx + d2;
if (canStand(L, br, nx, s.fy)) { const ns = { fx: nx, fy: s.fy }; const k = key(ns); if (!seen.has(k)) { seen.add(k); q.push(ns); note(ns); } }
}
for (const d2 of [-1, 1]) { const r = fall(L, br, s.fx, s.fy, d2); if (r && canStand(L, br, r.fx, r.fy)) { const k = key(r); if (!seen.has(k)) { seen.add(k); q.push(r); note(r); } } }
for (const vx of [-HSPD, 0, HSPD]) for (const dj of [0, 6, 12, 18, 24, 30]) {
const r = jump(L, br, s.fx, s.fy, vx, dj, 0);
if (r && canStand(L, br, r.fx, r.fy)) { const k = key(r); if (!seen.has(k)) { seen.add(k); q.push(r); note(r); } }
if (vx !== 0) { const r2 = jump(L, br, s.fx, s.fy, vx, dj, 22); if (r2 && canStand(L, br, r2.fx, r2.fy)) { const k = key(r2); if (!seen.has(k)) { seen.add(k); q.push(r2); note(r2); } } }
}
}
console.log(`${id} ${d.name} ${L.w}x${L.h} 可达站立行数=${byRow.size} 状态=${seen.size}`);
[...byRow.keys()].sort((a, b) => b - a).forEach(r => {
const v = byRow.get(r);
console.log(` row ${String(r).padStart(2)} (y=${r * TILE}): x ${(v.min / TILE).toFixed(1)} .. ${(v.max / TILE).toFixed(1)} 格`);
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
const RELSET = (process.env.REL || '0,4,8,14').split(',').map(Number);
const r = reach(L, {
DJSET, RELSET,
onState: s => {
const row = Math.round(s.fy / TILE);
const cur = byRow.get(row) || { max: -1, min: 1e9 };
byRow.set(row, { max: Math.max(cur.max, s.fx), min: Math.min(cur.min, s.fx) });
},
});
console.log(`${id} ${d.name} ${L.w}x${L.h} 状态=${r.states}${r.err ? ' ERR:' + r.err : ''}`);
[...byRow.keys()].sort((a, b) => b - a).forEach(row => {
const v = byRow.get(row);
console.log(` row ${String(row).padStart(2)} (y=${row * TILE}): x ${(v.min / TILE).toFixed(1)} .. ${(v.max / TILE).toFixed(1)} 格`);
});
// 打印目标
for (const e of L.entities) if (e.type === 'goal' || e.type === 'save')
console.log(` ${e.type}#${e.no ?? ''} at col ${e.tx}, row ${e.ty}`);
+16 -186
View File
@@ -1,195 +1,25 @@
// 可达性求解器:用真实物理常量做 BFS,验证终点 / 存档点能否到达
const handlers = {};
const addL = (t, fn) => { (handlers[t] = handlers[t] || []).push(fn); };
globalThis.addEventListener = addL;
globalThis.window = { addEventListener: addL };
// 可达性求解器:验证每关终点 / 存档点 / 苹果能否到达
// DJ=0,12,24 收紧二段跳时机;REL=0 禁用短跳 —— 检验是否只有"某一帧"能过
import { setupGlobals, reach, makeTargets } from './lib-reach.mjs';
setupGlobals();
const { LEVELS } = await import('../js/levels/index.js');
const { Level } = await import('../js/level.js');
// 可用 DJ=0,12,24 收紧二段跳时机选项,检验关卡是不是只有"某一帧"能过
const DJSET = (process.env.DJ || '0,6,12,18,24,30').split(',').map(Number);
const TILE = 32, PW = 14, PH = 18;
const GRAV = 0.4, MAXFALL = 9, JUMP = 8.5, DJUMP = 7.6, HSPD = 3;
const RELSET = (process.env.REL || '0,4,8,14').split(',').map(Number);
// ---- 碰撞(单向平台:只有下落且上一帧还在平台面之上才挡)----
function solidAt(L, br, x, y, w, h, down, prevBottom) {
const x0 = Math.floor(x / TILE), x1 = Math.floor((x + w - 0.001) / TILE);
const y0 = Math.floor(y / TILE), y1 = Math.floor((y + h - 0.001) / TILE);
for (let ty = y0; ty <= y1; ty++) {
for (let tx = x0; tx <= x1; tx++) {
const t = L.tileAt(tx, ty);
if (t === 7) {
if (down && prevBottom !== undefined && prevBottom <= ty * TILE + 6) return true;
continue;
}
if (L.isSolidTile(t, tx, ty)) return true;
}
}
for (const b of br)
if (x < b.x + b.w && x + w > b.x && y < b.y + b.h && y + h > b.y) return true;
return false;
}
const overlap = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
/** 站立位置 (fx,fy)=脚底中心左端;返回是否合法 */
function canStand(L, br, fx, fy) {
if (fx < 0 || fx + PW > L.pxW) return false;
if (solidAt(L, br, fx, fy - PH, PW, PH, false)) return false;
if (!solidAt(L, br, fx, fy - PH + 1, PW, PH, true, fy - 1)) return false;
if (L.deadlyRect(fx, fy - PH, PW, PH)) return false;
return true;
}
/** 模拟一次跳跃:返回 {fx,fy} 落点 或 null;途中记录是否碰到目标 */
function jump(L, br, fx, fy, vx0, djump, flip, targets) {
let x = fx, y = fy - PH;
let vy = -JUMP, vx = vx0, t = 0, used = false;
while (t++ < 240) {
if (djump && !used && t === djump) { vy = -DJUMP; used = true; }
if (flip && t === flip) vx = -vx;
vy = Math.min(vy + GRAV, MAXFALL);
if (vx !== 0) {
const nx = x + vx;
if (!solidAt(L, br, nx, y, PW, PH, false)) x = nx; else vx = 0;
}
const prevBottom = y + PH;
let rem = Math.abs(vy), dy = Math.sign(vy), landed = false;
while (rem > 0.0001) {
const m = Math.min(1, rem);
const ny = y + dy * m;
if (!solidAt(L, br, x, ny, PW, PH, dy > 0, prevBottom)) { y = ny; rem -= m; }
else { if (dy > 0) landed = true; vy = 0; break; }
}
if (L.deadlyRect(x, y, PW, PH)) return null;
if (targets) {
const box = { x, y, w: PW, h: PH };
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
}
if (y > L.pxH + 120 || y < -240) return null;
if (landed) return { fx: x, fy: y + PH };
}
return null;
}
/** 走下平台:原地自由落体 */
function fall(L, br, fx, fy, dir, targets) {
let x = fx + dir * 3, y = fy - PH, vy = 0, t = 0;
if (solidAt(L, br, x, y, PW, PH, false)) return null;
while (t++ < 400) {
vy = Math.min(vy + GRAV, MAXFALL);
const prevBottom = y + PH;
let rem = Math.abs(vy), landed = false;
while (rem > 0.0001) {
const m = Math.min(1, rem);
const ny = y + m;
if (!solidAt(L, br, x, ny, PW, PH, true, prevBottom)) { y = ny; rem -= m; }
else { landed = true; break; }
}
if (L.deadlyRect(x, y, PW, PH)) return null;
if (targets) {
const box = { x, y, w: PW, h: PH };
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
}
if (y > L.pxH + 120) return null;
if (landed) return { fx: x, fy: y + PH };
}
return null;
}
function solve(d) {
const L = new Level(d);
const br = [];
// 水:视为可站立的表面(游泳近似)
for (let y = 0; y < L.h; y++)
for (let x = 0; x < L.w; x++)
if (L.tiles[y][x] === 6) L.tiles[y][x] = 1;
// 移动平台:整条路径当作桥(乐观)
for (const m of L.movers) {
const xs = m.path.map(p => p.x).concat([m.x]);
const ys = m.path.map(p => p.y).concat([m.y]);
const x0 = Math.min(...xs), x1 = Math.max(...xs) + m.w;
const y0 = Math.min(...ys), y1 = Math.max(...ys) + m.h;
br.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
}
const targets = [];
for (const e of L.entities) {
if (e.type === 'goal' || e.type === 'save' || e.type === 'apple')
targets.push({ type: e.type, idx: e.idx, rect: { x: e.x, y: e.y, w: e.w, h: e.h }, hit: false });
}
// 起点:从出生点往下找落脚点
let sx = L.spawn.x, sy = L.spawn.y + PH;
let start = null;
for (let k = 0; k < 400; k++) {
if (canStand(L, br, sx, sy + k)) { start = { fx: sx, fy: sy + k }; break; }
if (sy + k > L.pxH) break;
}
if (!start) return { err: '出生点下方 400px 内找不到落脚点', targets };
const key = s => `${Math.round(s.fx / 6)},${Math.round(s.fy / 6)}`;
const seen = new Set([key(start)]);
const q = [start];
let n = 0, steps = 0;
// 起跳时就检查一次(原地也算到过)
while (q.length && n < 40000) {
const s = q.shift(); n++;
const box = { x: s.fx, y: s.fy - PH, w: PW, h: PH };
for (const tg of targets) if (!tg.hit && overlap(box, tg.rect)) tg.hit = true;
// 走路
for (const d2 of [-HSPD, HSPD]) {
const nx = s.fx + d2;
if (canStand(L, br, nx, s.fy)) {
const ns = { fx: nx, fy: s.fy };
const k = key(ns);
if (!seen.has(k)) { seen.add(k); q.push(ns); }
}
}
// 走下悬崖
for (const d2 of [-1, 1]) {
const r = fall(L, br, s.fx, s.fy, d2, targets);
steps++;
if (r && canStand(L, br, r.fx, r.fy)) {
const k = key(r);
if (!seen.has(k)) { seen.add(k); q.push(r); }
}
}
// 跳跃(含二段跳时机 / 空中转向)
for (const vx of [-HSPD, 0, HSPD]) {
for (const dj of DJSET) {
const r = jump(L, br, s.fx, s.fy, vx, dj, 0, targets);
steps++;
if (r && canStand(L, br, r.fx, r.fy)) {
const k = key(r);
if (!seen.has(k)) { seen.add(k); q.push(r); }
}
if (vx !== 0) {
const r2 = jump(L, br, s.fx, s.fy, vx, dj, 22, targets);
steps++;
if (r2 && canStand(L, br, r2.fx, r2.fy)) {
const k = key(r2);
if (!seen.has(k)) { seen.add(k); q.push(r2); }
}
}
}
}
}
return { states: seen.size, steps, targets };
}
// ---------------- 运行 ----------------
let fail = 0;
console.log('=== 可达性求解 ===');
console.log(`=== 可达性求解 (DJ=${DJSET} REL=${RELSET}) ===`);
for (const d of LEVELS) {
if (d.type === 'boss') { console.log(` ${d.id.padEnd(3)} ${d.name.padEnd(17)} (BOSS 关跳过)`); continue; }
if (d.type === 'boss') { console.log(` ${d.id.padEnd(3)} ${d.name.padEnd(17)} (BOSS 关跳过)`); continue; }
let r;
try { r = solve(d); }
catch (e) { console.log(` ✗ ${d.id} 求解异常: ${e.message}`); fail++; continue; }
try {
const { Level } = await import('../js/level.js');
const L = new Level(d);
const targets = makeTargets(L);
r = reach(L, { DJSET, RELSET, targets });
r.targets = targets;
} catch (e) { console.log(` ✗ ${d.id} 求解异常: ${e.message}`); fail++; continue; }
if (r.err) { console.log(` ✗ ${d.id.padEnd(3)} ${d.name.padEnd(17)} ${r.err}`); fail++; continue; }
const goal = r.targets.find(t => t.type === 'goal');
@@ -197,11 +27,11 @@ for (const d of LEVELS) {
const apples = r.targets.filter(t => t.type === 'apple');
const okSave = saves.filter(s => s.hit).length;
const okApple = apples.filter(a => a.hit).length;
const badSave = saves.filter(s => !s.hit).map(s => `#${s.idx}`);
const badSave = saves.filter(s => !s.hit).map(s => `#${s.no ?? s.idx}`);
const flag = goal && goal.hit ? ' ' : '✗';
if (!goal || !goal.hit) fail++;
if (badSave.length) fail++;
console.log(` ${flag} ${d.id.padEnd(3)} ${d.name.padEnd(17)} 终点=${goal ? (goal.hit ? 'OK' : '不可达') : '无'} 存档=${okSave}/${saves.length}${badSave.length ? ' 缺:' + badSave.join(',') : ''}${apples.length ? ` 苹果=${okApple}/${apples.length}` : ''} (状态${r.states} 模拟${r.steps})`);
console.log(` ${flag} ${d.id.padEnd(3)} ${d.name.padEnd(17)} 终点=${goal ? (goal.hit ? 'OK' : '不可达') : '无'} 存档=${okSave}/${saves.length}${badSave.length ? ' 缺:' + badSave.join(',') : ''}${apples.length ? ` 苹果=${okApple}/${apples.length}` : ''} (状态${r.states})`);
}
console.log(fail ? `\n❌ ${fail} 项不可达` : '\n✅ 所有关卡终点与存档点均可达');
process.exit(fail ? 1 : 0);