feat: 新增3个思想实验室项目,完善私有剪贴板授权与上传功能,添加配套测试脚本
1. 新增怀旧弹珠台、人性与投资、穿越三体、四维空间四个思想实验室项目 2. 为私有剪贴板添加业务层授权校验,优化上传进度显示与鉴权失败处理 3. 新增多个调试与单元测试脚本,覆盖三体积分、弹珠台物理、集成测试等场景 4. 补全穿越三体项目的完整HTML页面资源
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 调试:retro_pinball 拱形导轨几何搜索
|
||||
* 目的:找到一组 ARCH(cx,cy,r,a0,a1),让满力发射的球能绕弧 -> 左侧落入 -> 静止落球区
|
||||
* 运行:node dev_test_scripts/debug/debug_retro_pinball_arch_search.js
|
||||
*/
|
||||
'use strict'
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const vm = require('vm')
|
||||
|
||||
const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html')
|
||||
|
||||
const html = fs.readFileSync(HTML_PATH, 'utf-8')
|
||||
const grab = (a, b) => {
|
||||
const s = html.indexOf(a), e = html.indexOf(b)
|
||||
if (s < 0 || e < 0 || e <= s) throw new Error('未找到标记块: ' + a)
|
||||
return html.slice(s, e)
|
||||
}
|
||||
const code =
|
||||
grab('/* ==PHYSICS_CORE_START== */', '/* ==PHYSICS_CORE_END== */') + '\n' +
|
||||
grab('/* ==TABLE_GEOM_START== */', '/* ==TABLE_GEOM_END== */')
|
||||
|
||||
const sandbox = { Math, console }
|
||||
vm.createContext(sandbox)
|
||||
vm.runInContext(code, sandbox)
|
||||
const PC = sandbox.PhysicsCore
|
||||
const { BALL_R, G_PX, SPRING_X, SPRING_TOP, LANE_X, buildTableStatics, slotIndexAt, SLOT_X0, SLOT_X1 } = sandbox
|
||||
|
||||
const DT = 1 / 360
|
||||
function defaultParams() {
|
||||
return {
|
||||
g: G_PX * Math.sin(14 * Math.PI / 180),
|
||||
e: 0.55, mu: 0.12, airK: 0.02, rollR: 0.25,
|
||||
restV: 50, iterations: 8, slop: 0.3, correction: 0.8, maxV: 4200
|
||||
};
|
||||
}
|
||||
|
||||
// 三点定圆
|
||||
function fitCircle(p1, p2, p3) {
|
||||
const [x1, y1] = p1, [x2, y2] = p2, [x3, y3] = p3
|
||||
const d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2))
|
||||
if (Math.abs(d) < 1e-9) return null
|
||||
const ux = ((x1 * x1 + y1 * y1) * (y2 - y3) + (x2 * x2 + y2 * y2) * (y3 - y1) + (x3 * x3 + y3 * y3) * (y1 - y2)) / d
|
||||
const uy = ((x1 * x1 + y1 * y1) * (x3 - x2) + (x2 * x2 + y2 * y2) * (x1 - x3) + (x3 * x3 + y3 * y3) * (x2 - x1)) / d
|
||||
const r = Math.hypot(x1 - ux, y1 - uy)
|
||||
return { cx: ux, cy: uy, r: r }
|
||||
}
|
||||
const deg = a => a * 180 / Math.PI
|
||||
function angleOf(c, p) { return deg(Math.atan2(p[1] - c.cy, p[0] - c.cx)) }
|
||||
|
||||
// 候选:三点(左出口 / 弧顶 / 右端),右端放在发射道上方,让球顺势切入
|
||||
// laneTop = 发射道左壁顶 (LANE_X,190);右端在右外壁 x=442 附近
|
||||
const candidates = []
|
||||
function addCandidate(leftExit, top, rightEnd, label) {
|
||||
const c = fitCircle(leftExit, top, rightEnd)
|
||||
if (!c) return
|
||||
// 角度范围:从右端 -> 顶部 -> 左出口(逆时针,y 向下故角度递减)
|
||||
let aR = angleOf(c, rightEnd)
|
||||
let aT = angleOf(c, top)
|
||||
let aL = angleOf(c, leftExit)
|
||||
// 归一化:弧要从左出口经弧顶(-90°)扫到右端,需 aL < -90 < aR(角度递增)
|
||||
while (aL > aR) aL -= 360
|
||||
candidates.push({ label, arch: { cx: c.cx, cy: c.cy, r: c.r, a0: aL, a1: aR }, _top: aT, c })
|
||||
}
|
||||
// 右端扫过发射道:从 (442,180) 到 (442,230),左出口落在钉阵左侧,顶部留 >=30 余量
|
||||
addCandidate([56, 330], [230, 42], [442, 180], 'C1 右端(442,180)')
|
||||
addCandidate([56, 330], [235, 45], [442, 200], 'C2 右端(442,200)')
|
||||
addCandidate([60, 340], [235, 48], [442, 210], 'C3 右端(442,210)')
|
||||
addCandidate([60, 340], [240, 50], [442, 220], 'C4 右端(442,220)')
|
||||
addCandidate([50, 320], [225, 40], [442, 190], 'C5 右端(442,190)')
|
||||
addCandidate([56, 335], [230, 44], [440, 195], 'C6 右端(440,195)')
|
||||
|
||||
function simulate(arch, v0, seconds, seedVx) {
|
||||
sandbox.ARCH = arch
|
||||
const table = buildTableStatics(5, 1)
|
||||
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R)
|
||||
b.vy = -v0; b.vx = seedVx || 0
|
||||
const tr = { minY: 1e9, enteredLeft: false, nan: false }
|
||||
for (let t = 0; t < seconds; t += DT) {
|
||||
PC.stepWorld([b], table.statics, defaultParams(), DT)
|
||||
tr.minY = Math.min(tr.minY, b.y)
|
||||
if (b.x < 90 && b.y > 200 && b.y < 450) tr.enteredLeft = true
|
||||
if (!isFinite(b.x) || !isFinite(b.y)) { tr.nan = true; break }
|
||||
}
|
||||
tr.b = b
|
||||
tr.sp = Math.hypot(b.vx, b.vy)
|
||||
tr.inField = b.x >= SLOT_X0 && b.x <= SLOT_X1
|
||||
tr.slot = slotIndexAt(b.x, b.y)
|
||||
return tr
|
||||
}
|
||||
|
||||
for (const cd of candidates) {
|
||||
const a = cd.arch
|
||||
const tr = simulate(a, 2900, 10, 5)
|
||||
const topOk = tr.minY < 90
|
||||
const ok = !tr.nan && topOk && tr.enteredLeft && tr.inField && tr.sp < 30
|
||||
console.log(`${ok ? '★' : ' '} ${cd.label} cx=${a.cx.toFixed(0)} cy=${a.cy.toFixed(0)} r=${a.r.toFixed(0)} a:[${a.a0.toFixed(0)}..${a.a1.toFixed(0)}] topAng=${cd._top.toFixed(0)}`)
|
||||
console.log(` minY=${tr.minY.toFixed(0)} enteredLeft=${tr.enteredLeft} final=(${tr.b.x.toFixed(0)},${tr.b.y.toFixed(0)}) sp=${tr.sp.toFixed(1)} inField=${tr.inField} slot=${tr.slot} nan=${tr.nan}`)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 调试:retro_pinball 拱形候选的力度扫描 + 精确参数输出
|
||||
* 运行:node dev_test_scripts/debug/debug_retro_pinball_arch_sweep.js
|
||||
*/
|
||||
'use strict'
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const vm = require('vm')
|
||||
|
||||
const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html')
|
||||
const html = fs.readFileSync(HTML_PATH, 'utf-8')
|
||||
const grab = (a, b) => {
|
||||
const s = html.indexOf(a), e = html.indexOf(b)
|
||||
if (s < 0 || e < 0 || e <= s) throw new Error('未找到标记块: ' + a)
|
||||
return html.slice(s, e)
|
||||
}
|
||||
const code = grab('/* ==PHYSICS_CORE_START== */', '/* ==PHYSICS_CORE_END== */') + '\n' + grab('/* ==TABLE_GEOM_START== */', '/* ==TABLE_GEOM_END== */')
|
||||
const sandbox = { Math, console }
|
||||
vm.createContext(sandbox)
|
||||
vm.runInContext(code, sandbox)
|
||||
const PC = sandbox.PhysicsCore
|
||||
const { BALL_R, G_PX, SPRING_X, SPRING_TOP, LANE_X, buildTableStatics, slotIndexAt, SLOT_X0, SLOT_X1 } = sandbox
|
||||
|
||||
const DT = 1 / 360
|
||||
function params() {
|
||||
return { g: G_PX * Math.sin(14 * Math.PI / 180), e: 0.55, mu: 0.12, airK: 0.02, rollR: 0.25, restV: 50, iterations: 8, slop: 0.3, correction: 0.8, maxV: 4200 };
|
||||
}
|
||||
function fitCircle(p1, p2, p3) {
|
||||
const [x1, y1] = p1, [x2, y2] = p2, [x3, y3] = p3
|
||||
const d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2))
|
||||
const ux = ((x1 * x1 + y1 * y1) * (y2 - y3) + (x2 * x2 + y2 * y2) * (y3 - y1) + (x3 * x3 + y3 * y3) * (y1 - y2)) / d
|
||||
const uy = ((x1 * x1 + y1 * y1) * (x3 - x2) + (x2 * x2 + y2 * y2) * (x1 - x3) + (x3 * x3 + y3 * y3) * (x2 - x1)) / d
|
||||
return { cx: ux, cy: uy, r: Math.hypot(x1 - ux, y1 - uy) }
|
||||
}
|
||||
const deg = a => a * 180 / Math.PI
|
||||
function makeArch(leftExit, top, rightEnd) {
|
||||
const c = fitCircle(leftExit, top, rightEnd)
|
||||
let aR = deg(Math.atan2(rightEnd[1] - c.cy, rightEnd[0] - c.cx))
|
||||
let aL = deg(Math.atan2(leftExit[1] - c.cy, leftExit[0] - c.cx))
|
||||
while (aL > aR) aL -= 360
|
||||
return { cx: c.cx, cy: c.cy, r: c.r, a0: aL, a1: aR }
|
||||
}
|
||||
|
||||
function simulate(arch, v0, seedVx) {
|
||||
sandbox.ARCH = arch
|
||||
const table = buildTableStatics(5, 1)
|
||||
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R)
|
||||
b.vy = -v0; b.vx = seedVx || 0
|
||||
let minY = 1e9, enteredLeft = false, nan = false
|
||||
for (let t = 0; t < 10; t += DT) {
|
||||
PC.stepWorld([b], table.statics, params(), DT)
|
||||
minY = Math.min(minY, b.y)
|
||||
if (b.x < 90 && b.y > 200 && b.y < 450) enteredLeft = true
|
||||
if (!isFinite(b.x) || !isFinite(b.y)) { nan = true; break }
|
||||
}
|
||||
const sp = Math.hypot(b.vx, b.vy)
|
||||
return { minY, enteredLeft, nan, x: b.x, y: b.y, sp, inField: b.x >= SLOT_X0 && b.x <= SLOT_X1, slot: slotIndexAt(b.x, b.y), fellBackLane: b.x > LANE_X };
|
||||
}
|
||||
|
||||
// 聚焦最优候选 C4,输出精确值
|
||||
const CHOSEN = { leftExit: [60, 340], top: [240, 50], rightEnd: [442, 220] }
|
||||
const arch = makeArch(CHOSEN.leftExit, CHOSEN.top, CHOSEN.rightEnd)
|
||||
console.log('选中拱形精确参数:')
|
||||
console.log(` cx=${arch.cx.toFixed(3)} cy=${arch.cy.toFixed(3)} r=${arch.r.toFixed(3)} a0=${arch.a0.toFixed(3)} a1=${arch.a1.toFixed(3)}`)
|
||||
console.log(` 左出口=(${CHOSEN.leftExit}) 弧顶=(${CHOSEN.top}) 右端=(${CHOSEN.rightEnd})`)
|
||||
console.log('')
|
||||
console.log('力度扫描(seedVx=5):')
|
||||
const powers = [900, 1200, 1500, 1800, 2100, 2400, 2700, 2900, 3200]
|
||||
for (const v0 of powers) {
|
||||
const r = simulate(arch, v0, 5)
|
||||
const tag = r.nan ? 'NaN!' : r.fellBackLane ? '落回发射道' : r.enteredLeft ? '绕弧左侧落入' : '其他'
|
||||
console.log(` v0=${v0} minY=${r.minY.toFixed(0)} ${tag} final=(${r.x.toFixed(0)},${r.y.toFixed(0)}) sp=${r.sp.toFixed(0)} slot=${r.slot}`)
|
||||
}
|
||||
console.log('')
|
||||
console.log('满力微扰扫描(v0=2900,落点分布):')
|
||||
const slots = {}
|
||||
for (let sv = -8; sv <= 8; sv += 2) {
|
||||
const r = simulate(arch, 2900, sv)
|
||||
slots[r.slot] = (slots[r.slot] || 0) + 1
|
||||
console.log(` seedVx=${sv} 落点=(${r.x.toFixed(0)},${r.y.toFixed(0)}) slot=${r.slot} enteredLeft=${r.enteredLeft}`)
|
||||
}
|
||||
console.log(' 落坑分布:', JSON.stringify(slots))
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 调试:retro_pinball 满力发射轨迹追踪
|
||||
* 目的:定位能量增益 bug(理论上满力发射到不了顶部,实测 minY=28)
|
||||
* 运行:node dev_test_scripts/debug/debug_retro_pinball_launch.js
|
||||
*/
|
||||
'use strict'
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const vm = require('vm')
|
||||
|
||||
const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html')
|
||||
|
||||
const html = fs.readFileSync(HTML_PATH, 'utf-8')
|
||||
const grab = (a, b) => {
|
||||
const s = html.indexOf(a), e = html.indexOf(b)
|
||||
if (s < 0 || e < 0 || e <= s) throw new Error('未找到标记块: ' + a)
|
||||
return html.slice(s, e)
|
||||
}
|
||||
const code =
|
||||
grab('/* ==PHYSICS_CORE_START== */', '/* ==PHYSICS_CORE_END== */') + '\n' +
|
||||
grab('/* ==TABLE_GEOM_START== */', '/* ==TABLE_GEOM_END== */')
|
||||
|
||||
const sandbox = { Math, console }
|
||||
vm.createContext(sandbox)
|
||||
vm.runInContext(code, sandbox)
|
||||
const PC = sandbox.PhysicsCore
|
||||
const { BALL_R, G_PX, SPRING_X, SPRING_TOP, buildTableStatics } = sandbox
|
||||
|
||||
const DT = 1 / 360
|
||||
function defaultParams() {
|
||||
return {
|
||||
g: G_PX * Math.sin(14 * Math.PI / 180),
|
||||
e: 0.55, mu: 0.12, airK: 0.02, rollR: 0.25,
|
||||
restV: 50, iterations: 8, slop: 0.3, correction: 0.8, maxV: 4200
|
||||
};
|
||||
}
|
||||
|
||||
const gEff = G_PX * Math.sin(14 * Math.PI / 180)
|
||||
console.log('g_eff =', gEff.toFixed(1), 'px/s^2')
|
||||
|
||||
const table = buildTableStatics(5, 1)
|
||||
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R)
|
||||
b.vy = -2900
|
||||
b.vx = 5
|
||||
const balls = [b]
|
||||
|
||||
let maxSp = 0, maxSpAt = ''
|
||||
let minY = 1e9, minYAt = ''
|
||||
let maxEnergy = -1e9, maxEnergyAt = ''
|
||||
const startEnergy = 0.5 * (b.vx * b.vx + b.vy * b.vy) + gEff * b.y
|
||||
console.log('初始动能+势能基准 E0 =', startEnergy.toFixed(0))
|
||||
|
||||
for (let i = 0; i < 360 * 3; i++) {
|
||||
PC.stepWorld(balls, table.statics, defaultParams(), DT)
|
||||
const sp = Math.hypot(b.vx, b.vy)
|
||||
const t = ((i + 1) / 360).toFixed(2)
|
||||
// 机械能 = 动能 + 势能(y 向下为正,势能取 g*y,越高 y 越小能量越小)
|
||||
const E = 0.5 * sp * sp + gEff * b.y
|
||||
if (sp > maxSp) { maxSp = sp; maxSpAt = `t=${t} pos=(${b.x.toFixed(1)},${b.y.toFixed(1)})` }
|
||||
if (b.y < minY) { minY = b.y; minYAt = `t=${t} pos=(${b.x.toFixed(1)},${b.y.toFixed(1)}) sp=${sp.toFixed(0)}` }
|
||||
if (E > maxEnergy) { maxEnergy = E; maxEnergyAt = `t=${t} pos=(${b.x.toFixed(1)},${b.y.toFixed(1)}) sp=${sp.toFixed(0)}` }
|
||||
if (i % 36 === 0) {
|
||||
console.log(`t=${t} pos=(${b.x.toFixed(1)},${b.y.toFixed(1)}) v=(${b.vx.toFixed(0)},${b.vy.toFixed(0)}) sp=${sp.toFixed(0)} E=${E.toFixed(0)}`)
|
||||
}
|
||||
}
|
||||
console.log('----')
|
||||
console.log('maxSp =', maxSp.toFixed(1), '@', maxSpAt)
|
||||
console.log('minY =', minY.toFixed(1), '@', minYAt)
|
||||
console.log('maxE =', maxEnergy.toFixed(0), '@', maxEnergyAt, '(E0=' + startEnergy.toFixed(0) + ')')
|
||||
@@ -0,0 +1,222 @@
|
||||
// ============================================================
|
||||
// dev_test_scripts/integration/test_retro_pinball_integration.js
|
||||
// 怀旧弹珠台 · 台面几何 + 物理核心 集成测试(无头模拟)
|
||||
// 从 index.html 提取 PHYSICS_CORE 与 TABLE_GEOM 两个纯函数块,
|
||||
// 模拟真实玩法流程,验证台面几何与物理手感:
|
||||
// A. 满力发射:球沿发射道上行 -> 绕过弧形导轨 -> 左侧落入钉阵 -> 静止在落球区
|
||||
// B. 弱力发射:球到不了顶部,落回弹簧待发射(同真实玩具)
|
||||
// C. 连续多球:不爆炸(无 NaN)、可堆叠静止
|
||||
// 运行:node dev_test_scripts/integration/test_retro_pinball_integration.js
|
||||
// ============================================================
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html');
|
||||
|
||||
function loadBlocks() {
|
||||
const html = fs.readFileSync(HTML_PATH, 'utf-8');
|
||||
const grab = (a, b) => {
|
||||
const s = html.indexOf(a), e = html.indexOf(b);
|
||||
if (s < 0 || e < 0 || e <= s) throw new Error('未找到标记块: ' + a);
|
||||
return html.slice(s, e);
|
||||
};
|
||||
const code =
|
||||
grab('/* ==PHYSICS_CORE_START== */', '/* ==PHYSICS_CORE_END== */') + '\n' +
|
||||
grab('/* ==TABLE_GEOM_START== */', '/* ==TABLE_GEOM_END== */');
|
||||
const sandbox = { Math, console };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(code, sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
const sb = loadBlocks();
|
||||
const PC = sb.PhysicsCore;
|
||||
const { W, H, LANE_X, BALL_R, SLOT_TOP, SLOT_X0, SLOT_X1, SPRING_X, SPRING_TOP, G_PX, buildTableStatics, slotIndexAt, WIPERS, makeWiperSegs, updateWiperSeg } = sb;
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function check(name, cond, detail) {
|
||||
if (cond) { passed++; console.log(` [PASS] ${name}`); }
|
||||
else { failed++; console.error(` [FAIL] ${name}${detail ? ' -> ' + detail : ''}`); }
|
||||
}
|
||||
|
||||
const DT = 1 / 360;
|
||||
function defaultParams() {
|
||||
return {
|
||||
g: G_PX * Math.sin(14 * Math.PI / 180),
|
||||
e: 0.55, mu: 0.12, airK: 0.02, rollR: 0.25,
|
||||
restV: 50, iterations: 8, slop: 0.3, correction: 0.8, maxV: 4200
|
||||
};
|
||||
}
|
||||
|
||||
function simulate(v0, seconds, seedVx) {
|
||||
const table = buildTableStatics(5, 1);
|
||||
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R);
|
||||
b.vy = -v0;
|
||||
b.vx = seedVx || 0;
|
||||
const trace = { minY: 1e9, enteredLeft: false, maxX: -1e9 };
|
||||
for (let t = 0; t < seconds; t += DT) {
|
||||
PC.stepWorld([b], table.statics, defaultParams(), DT);
|
||||
trace.minY = Math.min(trace.minY, b.y);
|
||||
trace.maxX = Math.max(trace.maxX, b.x);
|
||||
if (b.x < 90 && b.y > 200 && b.y < 450) trace.enteredLeft = true;
|
||||
if (!isFinite(b.x) || !isFinite(b.y)) { trace.nan = true; break; }
|
||||
}
|
||||
trace.ball = b;
|
||||
return trace;
|
||||
}
|
||||
|
||||
// ---------- A. 满力发射完整一圈 ----------
|
||||
(function testFullLaunch() {
|
||||
console.log('A) 满力发射:绕弧 -> 左侧落入 -> 静止落球区');
|
||||
const tr = simulate(2900, 10, 5);
|
||||
check('无 NaN', !tr.nan);
|
||||
// 球沿拱形内侧绕行,满力时球心最低约 y≈90(拱顶 y=50 + 球半径 + 脱弧抛体),
|
||||
// 故以「越过发射道顶(y=190)进入拱形区」为到达判据
|
||||
check(`到达拱形区 minY=${tr.minY.toFixed(0)} < 150`, tr.minY < 150, `minY=${tr.minY}`);
|
||||
check('从左侧落入钉阵', tr.enteredLeft);
|
||||
const b = tr.ball;
|
||||
const sp = Math.hypot(b.vx, b.vy);
|
||||
check(`10s 后基本静止 sp=${sp.toFixed(1)} < 30`, sp < 30, `sp=${sp}`);
|
||||
check(`静止在落球区 y=${b.y.toFixed(0)} > 500`, b.y > 500, `y=${b.y}`);
|
||||
check(`静止在台面内 x=${b.x.toFixed(0)} in [18,378]`, b.x >= SLOT_X0 && b.x <= SLOT_X1, `x=${b.x}`);
|
||||
console.log(` -> 落点坑位: ${slotIndexAt(b.x, b.y)}`);
|
||||
})();
|
||||
|
||||
// ---------- B. 弱力发射落回弹簧 ----------
|
||||
(function testWeakLaunch() {
|
||||
console.log('B) 弱力发射:到不了顶部,落回发射道');
|
||||
const tr = simulate(1400, 5, 0);
|
||||
check('无 NaN', !tr.nan);
|
||||
check(`到不了顶部 minY=${tr.minY.toFixed(0)} > 200`, tr.minY > 200, `minY=${tr.minY}`);
|
||||
const b = tr.ball;
|
||||
check(`落回发射道 x=${b.x.toFixed(0)} > ${LANE_X}`, b.x > LANE_X, `x=${b.x}`);
|
||||
check(`停在弹簧附近 y=${b.y.toFixed(0)} > 650`, b.y > 650, `y=${b.y}`);
|
||||
})();
|
||||
|
||||
// ---------- C. 连续多球不爆炸、可堆叠 ----------
|
||||
(function testMultiBall() {
|
||||
console.log('C) 连续 4 球:数值稳定、落坑堆叠');
|
||||
const table = buildTableStatics(5, 1);
|
||||
const balls = [];
|
||||
let nan = false;
|
||||
for (let n = 0; n < 4; n++) {
|
||||
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R);
|
||||
b.vy = -2900;
|
||||
b.vx = (n % 2 === 0 ? -1 : 1) * 8; // 微小扰动让落点分散
|
||||
balls.push(b);
|
||||
// 模拟到该球基本静止(最多 9s),期间所有球一起步进
|
||||
for (let t = 0; t < 9; t += DT) {
|
||||
PC.stepWorld(balls, table.statics, defaultParams(), DT);
|
||||
for (const x of balls) if (!isFinite(x.x) || !isFinite(x.y)) nan = true;
|
||||
const sp = Math.hypot(b.vx, b.vy);
|
||||
if (t > 2 && sp < 15) break;
|
||||
}
|
||||
}
|
||||
check('无 NaN', !nan);
|
||||
const settled = balls.filter(b => Math.hypot(b.vx, b.vy) < 30 && b.y > 500 && b.x <= SLOT_X1);
|
||||
check(`至少 3 球静止在落球区(实际 ${settled.length})`, settled.length >= 3, `settled=${settled.length}`);
|
||||
// 堆叠检查:同一坑允许多球且互不重叠穿透
|
||||
for (let i = 0; i < balls.length; i++) {
|
||||
for (let j = i + 1; j < balls.length; j++) {
|
||||
const d = Math.hypot(balls[i].x - balls[j].x, balls[i].y - balls[j].y);
|
||||
if (d < (BALL_R * 2) - 2) check(`球${i}/${j} 不深叠 d=${d.toFixed(1)}`, false, `d=${d}`);
|
||||
}
|
||||
}
|
||||
check('多球间距正常', true);
|
||||
console.log(' -> 落点: ' + balls.map(b => `(${b.x.toFixed(0)},${b.y.toFixed(0)})`).join(' '));
|
||||
})();
|
||||
|
||||
// ---------- E. 力度曲线:20%/60% 蓄力都能进盘面(与游戏层 launch() 同式映射) ----------
|
||||
(function testChargeCurve() {
|
||||
console.log('E) 力度曲线:20%/60% 蓄力均进盘面');
|
||||
const L = 2900, ENTER_V = 2400;
|
||||
const map = ch => ch <= 0.2 ? 1000 + (ch / 0.2) * (ENTER_V - 1000) : ENTER_V + ((ch - 0.2) / 0.8) * (L - ENTER_V);
|
||||
for (const ch of [0.2, 0.6]) {
|
||||
const tr = simulate(map(ch), 10, 5);
|
||||
const b = tr.ball;
|
||||
// 进盘面判据:越过发射道顶(y=190);弱力对照 B 的 minY=491 远大于此
|
||||
check(`蓄力${(ch * 100).toFixed(0)}% v0=${map(ch).toFixed(0)} 进入盘面`, tr.minY < 190, `minY=${tr.minY}`);
|
||||
check(`蓄力${(ch * 100).toFixed(0)}% 静止落球区`, Math.hypot(b.vx, b.vy) < 30 && b.y > 500 && b.x <= SLOT_X1, `(${b.x.toFixed(0)},${b.y.toFixed(0)})`);
|
||||
}
|
||||
})();
|
||||
|
||||
// 拨片回合模拟:3 连满力发射 + 3s 沉降冷却(同真实回合等静止结算)
|
||||
function runWiperRound(spin) {
|
||||
const table = buildTableStatics(5, 1);
|
||||
const wsegs = makeWiperSegs();
|
||||
const statics = table.statics.concat(wsegs);
|
||||
const balls = [];
|
||||
let nan = false;
|
||||
let t = 0;
|
||||
const step = () => {
|
||||
t += DT;
|
||||
for (let i = 0; i < wsegs.length; i++) updateWiperSeg(wsegs[i], WIPERS[i], t, spin);
|
||||
PC.stepWorld(balls, statics, defaultParams(), DT);
|
||||
for (const x of balls) if (!isFinite(x.x) || !isFinite(x.y)) nan = true;
|
||||
};
|
||||
for (let n = 0; n < 3; n++) {
|
||||
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R);
|
||||
b.vy = -2900; b.vx = 5;
|
||||
balls.push(b);
|
||||
for (let s = 0; s < 10; s += DT) {
|
||||
step();
|
||||
const sp = Math.hypot(b.vx, b.vy);
|
||||
if (s > 2 && sp < 15) break;
|
||||
}
|
||||
}
|
||||
for (let s = 0; s < 3; s += DT) step(); // 沉降冷却
|
||||
return { balls, nan };
|
||||
}
|
||||
|
||||
// ---------- F. 雨刮拨片:运动碰撞体数值稳定、球最终落下静止 ----------
|
||||
(function testWipers() {
|
||||
console.log('F) 雨刮拨片开:3 连满力发射稳定、无 NaN、落底静止');
|
||||
const { balls, nan } = runWiperRound(false);
|
||||
check('拨片无 NaN', !nan);
|
||||
const settled = balls.filter(b => Math.hypot(b.vx, b.vy) < 30 && b.y > 400);
|
||||
check(`拨片下 3 球全部落底静止(实际 ${settled.length}/3)`, settled.length === 3, `settled=${settled.length}`);
|
||||
console.log(' -> 落点: ' + balls.map(b => `(${b.x.toFixed(0)},${b.y.toFixed(0)})`).join(' '));
|
||||
})();
|
||||
|
||||
// ---------- G. 防穿透回归:maxV=4200 垂直砸向胶囊拨片不得穿模 ----------
|
||||
(function testNoTunnel() {
|
||||
console.log('G) 4200px/s 垂直砸拨片不穿透');
|
||||
const seg = { type: 'seg', x1: 90, y1: 300, x2: 210, y2: 300, thk: 4, mov: { px: 150, py: 300, omega: 0 } };
|
||||
const b = PC.createBall(150, 100, BALL_R);
|
||||
b.vy = 4200;
|
||||
let maxY = -1e9;
|
||||
for (let t = 0; t < 1.5; t += DT) {
|
||||
PC.stepWorld([b], [seg], defaultParams(), DT);
|
||||
maxY = Math.max(maxY, b.y);
|
||||
}
|
||||
// 未穿透:球心不得越过 线y-(r+thk)+slop 容忍带(300-15+0.3≈285)
|
||||
check(`maxY=${maxY.toFixed(1)} < 292(未穿到线下)`, maxY < 292, `maxY=${maxY}`);
|
||||
})();
|
||||
|
||||
// ---------- H. 转动模式:3 连满力发射稳定、无 NaN、落底静止 ----------
|
||||
(function testSpin() {
|
||||
console.log('H) 转动模式:3 连满力发射稳定');
|
||||
const { balls, nan } = runWiperRound(true);
|
||||
check('转动无 NaN', !nan);
|
||||
const settled = balls.filter(b => Math.hypot(b.vx, b.vy) < 30 && b.y > 400);
|
||||
check(`转动下 3 球全部落底静止(实际 ${settled.length}/3)`, settled.length === 3, `settled=${settled.length}`);
|
||||
console.log(' -> 落点: ' + balls.map(b => `(${b.x.toFixed(0)},${b.y.toFixed(0)})`).join(' '));
|
||||
})();
|
||||
|
||||
// ---------- D. 无卡球缝:旧缝隙位置(378..398)垂直落球必须落底静止 ----------
|
||||
(function testNoWedge() {
|
||||
console.log('D) 无卡球缝:旧缝隙 x=388/395 垂直落球落底静止');
|
||||
const table = buildTableStatics(5, 1);
|
||||
for (const x0 of [388, 395]) {
|
||||
const b = PC.createBall(x0, 400, BALL_R);
|
||||
for (let t = 0; t < 5; t += DT) PC.stepWorld([b], table.statics, defaultParams(), DT);
|
||||
const sp = Math.hypot(b.vx, b.vy);
|
||||
check(`x0=${x0} 落底 y=${b.y.toFixed(0)} > 600`, b.y > 600, `y=${b.y}`);
|
||||
check(`x0=${x0} 静止 sp=${sp.toFixed(1)} < 30`, sp < 30, `sp=${sp}`);
|
||||
}
|
||||
})();
|
||||
|
||||
console.log(`\n结果: ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,83 @@
|
||||
// ============================================================
|
||||
// test_four_dim_polytopes.js - 四维空间实验室数学块校验
|
||||
// 从 public/tools/thought_lab/labs/four_dim_space/index.html 抽取
|
||||
// POLYTOPE_MATH_BEGIN/END 之间的纯数学代码执行校验,保证测试对象 = 上线代码。
|
||||
// 校验:5 个凸正多胞体的顶点数/边数(严格已知值)、外接半径全等、
|
||||
// 旋转保范、投影 w=0 时缩放为 1、偶排列数 = 12。
|
||||
// 运行:node dev_test_scripts/unit/test_four_dim_polytopes.js
|
||||
// ============================================================
|
||||
'use strict'
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const vm = require('vm')
|
||||
|
||||
const HTML = path.resolve(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'four_dim_space', 'index.html')
|
||||
const src = fs.readFileSync(HTML, 'utf8')
|
||||
const m = src.match(/\/\/ ==== POLYTOPE_MATH_BEGIN ====\n([\s\S]*?)\/\/ ==== POLYTOPE_MATH_END ====/)
|
||||
if (!m) { console.error('FAIL - 未找到 POLYTOPE_MATH 标记块'); process.exit(1) }
|
||||
|
||||
const api = vm.runInNewContext(m[1] + `
|
||||
;({ POLYTOPES, edgesByMinDist, rotate4, project4to3, project3to2, evenPerms4, perms4, PHI })
|
||||
`)
|
||||
|
||||
let pass = 0, fail = 0
|
||||
const check = (name, cond) => {
|
||||
if (cond) { pass++; console.log('PASS -', name) }
|
||||
else { fail++; console.log('FAIL -', name) }
|
||||
}
|
||||
|
||||
const norm = v => Math.sqrt(v.reduce((s, x) => s + x * x, 0))
|
||||
|
||||
// 排列数
|
||||
check('4 元素全排列 = 24', api.perms4.length === 24)
|
||||
check('偶排列 = 12', api.evenPerms4.length === 12)
|
||||
|
||||
// 各多胞体:顶点数 / 边数 / 外接半径全等
|
||||
for (const key of Object.keys(api.POLYTOPES)) {
|
||||
const P = api.POLYTOPES[key]
|
||||
const verts = P.verts()
|
||||
const edges = api.edgesByMinDist(verts)
|
||||
check(`${key} 顶点数 = ${P.expect.v}`, verts.length === P.expect.v)
|
||||
check(`${key} 边数 = ${P.expect.e}`, edges.length === P.expect.e)
|
||||
const rs = verts.map(norm)
|
||||
const rMin = Math.min(...rs), rMax = Math.max(...rs)
|
||||
check(`${key} 外接半径全等(${rMin.toFixed(6)})`, rMax - rMin < 1e-9)
|
||||
}
|
||||
|
||||
// 600-胞体最小边长应 = 1/φ(单位外接半径)
|
||||
{
|
||||
const verts = api.POLYTOPES.p600.verts()
|
||||
let min = Infinity
|
||||
for (let i = 0; i < verts.length; i++)
|
||||
for (let j = i + 1; j < verts.length; j++) {
|
||||
let s = 0
|
||||
for (let k = 0; k < 4; k++) { const d = verts[i][k] - verts[j][k]; s += d * d }
|
||||
min = Math.min(min, s)
|
||||
}
|
||||
check('600-胞体边长 = 1/φ', Math.abs(Math.sqrt(min) - 1 / api.PHI) < 1e-9)
|
||||
}
|
||||
|
||||
// 旋转保范:六个平面各转 0.7 rad
|
||||
{
|
||||
const v = [1, 2, 3, 4]
|
||||
const planes = [[0, 1], [0, 2], [1, 2], [0, 3], [1, 3], [2, 3]]
|
||||
let ok = true
|
||||
for (const [i, j] of planes) {
|
||||
const q = api.rotate4(v, i, j, 0.7)
|
||||
if (Math.abs(norm(q) - norm(v)) > 1e-12) ok = false
|
||||
}
|
||||
check('六平面旋转均保范', ok)
|
||||
}
|
||||
|
||||
// 投影:w=0 时 4D→3D 缩放为 1;w>0 放大
|
||||
{
|
||||
const a = api.project4to3([1, 1, 1, 0], 3)
|
||||
const b = api.project4to3([1, 1, 1, 0.9], 3)
|
||||
check('project4to3 w=0 缩放=1', Math.abs(a[0] - 1) < 1e-12)
|
||||
check('project4to3 w>0 放大', b[0] > 1)
|
||||
const c = api.project3to2([1, 1, 0], 6)
|
||||
check('project3to2 z=0 缩放=1', Math.abs(c[0] - 1) < 1e-12)
|
||||
}
|
||||
|
||||
console.log(fail === 0 ? `ALL PASS (${pass})` : `FAILED: ${fail} / ${pass + fail}`)
|
||||
process.exit(fail === 0 ? 0 : 1)
|
||||
@@ -0,0 +1,152 @@
|
||||
// ============================================================
|
||||
// dev_test_scripts/unit/test_retro_pinball_physics.js
|
||||
// 怀旧弹珠台 · 物理核心单元测试
|
||||
// 从 public/tools/thought_lab/labs/retro_pinball/index.html 中
|
||||
// 提取 ==PHYSICS_CORE_START== / ==PHYSICS_CORE_END== 之间的纯函数核心,
|
||||
// 在 node vm 中运行并断言物理正确性。
|
||||
// 运行:node dev_test_scripts/unit/test_retro_pinball_physics.js
|
||||
// ============================================================
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html');
|
||||
|
||||
function loadCore() {
|
||||
const html = fs.readFileSync(HTML_PATH, 'utf-8');
|
||||
const s = html.indexOf('/* ==PHYSICS_CORE_START== */');
|
||||
const e = html.indexOf('/* ==PHYSICS_CORE_END== */');
|
||||
if (s < 0 || e < 0 || e <= s) throw new Error('未找到物理核心标记块');
|
||||
const code = html.slice(s, e);
|
||||
const sandbox = { Math, console };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(code, sandbox);
|
||||
if (!sandbox.PhysicsCore) throw new Error('PhysicsCore 未导出');
|
||||
return sandbox.PhysicsCore;
|
||||
}
|
||||
|
||||
const PC = loadCore();
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function check(name, cond, detail) {
|
||||
if (cond) { passed++; console.log(` [PASS] ${name}`); }
|
||||
else { failed++; console.error(` [FAIL] ${name}${detail ? ' -> ' + detail : ''}`); }
|
||||
}
|
||||
|
||||
const DT = 1 / 360;
|
||||
function baseParams(over) {
|
||||
return Object.assign({
|
||||
g: 2000, e: 0.55, mu: 0.12, airK: 0, rollR: 0,
|
||||
restV: 1, iterations: 8, slop: 0.3, correction: 0.8, maxV: 6000
|
||||
}, over || {});
|
||||
}
|
||||
const FLOOR = { type: 'seg', x1: -500, y1: 500, x2: 500, y2: 500 };
|
||||
|
||||
// ---------- 1. 恢复系数:回弹高度 ≈ e²·h ----------
|
||||
(function testRestitution() {
|
||||
console.log('1) 恢复系数回弹高度');
|
||||
const e = 0.5, h = 300;
|
||||
const p = baseParams({ e, mu: 0, restV: 1 });
|
||||
const b = PC.createBall(0, 500 - 11 - h, 11);
|
||||
const statics = [FLOOR];
|
||||
let bounced = false, apex = 1e9, done = false, t = 0;
|
||||
while (t < 3 && !done) {
|
||||
PC.stepWorld([b], statics, p, DT);
|
||||
t += DT;
|
||||
if (!bounced && b.vy < 0) bounced = true; // 回弹开始(y 向下,vy<0 为向上)
|
||||
if (bounced) {
|
||||
apex = Math.min(apex, b.y);
|
||||
if (b.vy > 0) done = true; // 到达顶点
|
||||
}
|
||||
}
|
||||
const rebound = (500 - 11) - apex;
|
||||
const expect = e * e * h;
|
||||
check(`回弹高度 ${rebound.toFixed(1)} ≈ e²h=${expect.toFixed(1)} (±10%)`, Math.abs(rebound - expect) <= expect * 0.1, `got=${rebound}`);
|
||||
})();
|
||||
|
||||
// ---------- 2. 能量不增 ----------
|
||||
(function testEnergy() {
|
||||
console.log('2) 机械能不自发增加');
|
||||
const p = baseParams({ e: 0.5, mu: 0 });
|
||||
const b = PC.createBall(0, 100, 11);
|
||||
const statics = [FLOOR];
|
||||
const E0 = 0.5 * b.m * (b.vx * b.vx + b.vy * b.vy) + b.m * p.g * (500 - b.y);
|
||||
let maxE = 0;
|
||||
for (let t = 0; t < 2; t += DT) {
|
||||
PC.stepWorld([b], statics, p, DT);
|
||||
const E = 0.5 * b.m * (b.vx * b.vx + b.vy * b.vy) + b.m * p.g * (500 - b.y);
|
||||
maxE = Math.max(maxE, E);
|
||||
}
|
||||
check(`maxE=${maxE.toFixed(0)} <= E0*1.02=${(E0 * 1.02).toFixed(0)}`, maxE <= E0 * 1.02);
|
||||
})();
|
||||
|
||||
// ---------- 3. 静止稳定:不沉陷、不抖动 ----------
|
||||
(function testRest() {
|
||||
console.log('3) 水平面静止稳定性');
|
||||
const p = baseParams({ e: 0.55, mu: 0.2 });
|
||||
const b = PC.createBall(0, 200, 11);
|
||||
const statics = [FLOOR];
|
||||
for (let t = 0; t < 3; t += DT) PC.stepWorld([b], statics, p, DT);
|
||||
const sp = Math.hypot(b.vx, b.vy);
|
||||
check(`静止速度 ${sp.toFixed(2)} < 20`, sp < 20);
|
||||
check(`不沉陷 y=${b.y.toFixed(2)} <= 489+1`, b.y <= 500 - 11 + 1);
|
||||
})();
|
||||
|
||||
// ---------- 4. 等质量弹性碰撞速度交换 ----------
|
||||
(function testMomentum() {
|
||||
console.log('4) 等质量 e=1 正碰速度交换');
|
||||
const p = baseParams({ g: 0, e: 1, mu: 0 });
|
||||
const a = PC.createBall(0, 0, 11); a.vx = 100;
|
||||
const b = PC.createBall(30, 0, 11);
|
||||
for (let t = 0; t < 0.5; t += DT) PC.stepWorld([a, b], [], p, DT);
|
||||
check(`a.vx=${a.vx.toFixed(1)}≈0`, Math.abs(a.vx) < 3, `got=${a.vx}`);
|
||||
check(`b.vx=${b.vx.toFixed(1)}≈100`, Math.abs(b.vx - 100) < 3, `got=${b.vx}`);
|
||||
})();
|
||||
|
||||
// ---------- 5. 高速不穿透 ----------
|
||||
(function testNoTunnel() {
|
||||
console.log('5) 3000px/s 不穿透薄壁');
|
||||
const p = baseParams({ g: 0, e: 0.5, mu: 0 });
|
||||
const wall = { type: 'seg', x1: 100, y1: -1000, x2: 100, y2: 1000 };
|
||||
const b = PC.createBall(0, 0, 11); b.vx = 3000;
|
||||
let maxX = -1e9;
|
||||
for (let t = 0; t < 0.5; t += DT) {
|
||||
PC.stepWorld([b], [wall], p, DT);
|
||||
maxX = Math.max(maxX, b.x);
|
||||
}
|
||||
check(`maxX=${maxX.toFixed(1)} <= 100`, maxX <= 100 + 0.5);
|
||||
})();
|
||||
|
||||
// ---------- 6. 摩擦减速 + 滚动建立 ----------
|
||||
(function testFrictionRoll() {
|
||||
console.log('6) 摩擦使滑动减速并建立滚动');
|
||||
const mk = (mu) => {
|
||||
const p = baseParams({ e: 0.3, mu, rollR: 0 });
|
||||
const b = PC.createBall(0, 500 - 11, 11); b.vx = 500;
|
||||
for (let t = 0; t < 0.5; t += DT) PC.stepWorld([b], [FLOOR], p, DT);
|
||||
return b;
|
||||
};
|
||||
const noF = mk(0), withF = mk(0.4);
|
||||
check(`无摩擦保持滑动 |vx|=${noF.vx.toFixed(0)} > 400`, noF.vx > 400);
|
||||
check(`有摩擦减速 vx=${withF.vx.toFixed(0)} < ${noF.vx.toFixed(0)}`, withF.vx < noF.vx - 50);
|
||||
check(`滚动建立 omega=${withF.omega.toFixed(1)} > 5`, withF.omega > 5);
|
||||
})();
|
||||
|
||||
// ---------- 7. 斜坡:无摩擦加速下滑 ----------
|
||||
(function testSlope() {
|
||||
console.log('7) 斜坡下滑加速度 ≈ g·sinθ');
|
||||
const theta = Math.PI / 6; // 30°
|
||||
const p = baseParams({ g: 2000, e: 0, mu: 0 });
|
||||
const slope = { type: 'seg', x1: 0, y1: 0, x2: 300, y2: 300 * Math.tan(theta) };
|
||||
const b = PC.createBall(50, 50 * Math.tan(theta) - 11, 11);
|
||||
const v0 = Math.hypot(b.vx, b.vy);
|
||||
for (let t = 0; t < 0.3; t += DT) PC.stepWorld([b], [slope], p, DT);
|
||||
const v1 = Math.hypot(b.vx, b.vy);
|
||||
const dv = v1 - v0;
|
||||
const expect = p.g * Math.sin(theta) * 0.3;
|
||||
check(`dv=${dv.toFixed(0)} ≈ g·sinθ·t=${expect.toFixed(0)} (±8%)`, Math.abs(dv - expect) <= expect * 0.08);
|
||||
})();
|
||||
|
||||
console.log(`\n结果: ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,53 @@
|
||||
// ============================================================
|
||||
// test_three_body_integrator.js - 穿越三体 · 三体积分器校验
|
||||
// 从 public/tools/thought_lab/labs/three_body_journey/index.html 抽取
|
||||
// THREEBODY_MATH_BEGIN/END 之间的纯物理代码执行校验(测试对象 = 上线代码)。
|
||||
// 校验:RK4 积分动量守恒、能量漂移极小、加速度满足牛顿第三定律对称性。
|
||||
// 运行:node dev_test_scripts/unit/test_three_body_integrator.js
|
||||
// ============================================================
|
||||
'use strict'
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const vm = require('vm')
|
||||
|
||||
const HTML = path.resolve(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'three_body_journey', 'index.html')
|
||||
const src = fs.readFileSync(HTML, 'utf8')
|
||||
const m = src.match(/\/\/ ==== THREEBODY_MATH_BEGIN ====\n([\s\S]*?)\/\/ ==== THREEBODY_MATH_END ====/)
|
||||
if (!m) { console.error('FAIL - 未找到 THREEBODY_MATH 标记块'); process.exit(1) }
|
||||
|
||||
const api = vm.runInNewContext(m[1] + ';({ tbAccel, tbStep, tbEnergy, tbMomentum })')
|
||||
|
||||
let pass = 0, fail = 0
|
||||
const check = (name, cond) => {
|
||||
if (cond) { pass++; console.log('PASS -', name) }
|
||||
else { fail++; console.log('FAIL -', name) }
|
||||
}
|
||||
|
||||
const masses = [1, 1, 1]
|
||||
const eps2 = 0.02
|
||||
let pos = [1, 0, -0.5, Math.sqrt(3) / 2, -0.5, -Math.sqrt(3) / 2]
|
||||
let vel = [0, 0.42, -0.38, -0.2, 0.38, -0.22]
|
||||
|
||||
// 牛顿第三定律:Σ mᵢaᵢ = 0(作用力反作用力,任意位形)
|
||||
{
|
||||
const ms = [1, 2, 0.5]
|
||||
const a = api.tbAccel([0, 0, 1, 0.3, -0.7, 1.2], ms, 0)
|
||||
let sx = 0, sy = 0
|
||||
for (let i = 0; i < 3; i++) { sx += ms[i] * a[2 * i]; sy += ms[i] * a[2 * i + 1] }
|
||||
check('Σ mᵢaᵢ = 0(作用反作用)', Math.abs(sx) < 1e-12 && Math.abs(sy) < 1e-12)
|
||||
}
|
||||
|
||||
// 动量守恒:跑 5000 步
|
||||
const p0 = api.tbMomentum(vel, masses)
|
||||
const E0 = api.tbEnergy(pos, vel, masses, eps2)
|
||||
for (let i = 0; i < 5000; i++) [pos, vel] = api.tbStep(pos, vel, masses, 0.002, eps2)
|
||||
const p1 = api.tbMomentum(vel, masses)
|
||||
const E1 = api.tbEnergy(pos, vel, masses, eps2)
|
||||
check('动量守恒(5000 步,|Δp|<1e-9)', Math.abs(p1[0] - p0[0]) < 1e-9 && Math.abs(p1[1] - p0[1]) < 1e-9)
|
||||
check(`能量漂移小(|ΔE/E0|<1e-3,实际 ${Math.abs((E1 - E0) / E0).toExponential(2)})`, Math.abs((E1 - E0) / E0) < 1e-3)
|
||||
|
||||
// 有限性:5000 步后位置/速度均为有限数(软化避免奇点崩溃)
|
||||
check('积分稳定(全部有限)', [...pos, ...vel].every(Number.isFinite))
|
||||
|
||||
console.log(fail === 0 ? `ALL PASS (${pass})` : `FAILED: ${fail} / ${pass + fail}`)
|
||||
process.exit(fail === 0 ? 0 : 1)
|
||||
Reference in New Issue
Block a user