等大机会,不做无效开火
+-
+
- 特点:一年出手不多,宁愿错过,也不愿凑合。 +
- 优点:交易次数少,更容易把精力留给真正有把握的节点。 +
- 风险:如果没有真本事,容易把“不会做”伪装成“我在等”。 +
- 一句白话:不开枪则已,一开枪就要先想好怎么带着利润离场。 +
diff --git a/dev_test_scripts/debug/debug_retro_pinball_arch_search.js b/dev_test_scripts/debug/debug_retro_pinball_arch_search.js new file mode 100644 index 0000000..db77a94 --- /dev/null +++ b/dev_test_scripts/debug/debug_retro_pinball_arch_search.js @@ -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}`) +} diff --git a/dev_test_scripts/debug/debug_retro_pinball_arch_sweep.js b/dev_test_scripts/debug/debug_retro_pinball_arch_sweep.js new file mode 100644 index 0000000..d7091ac --- /dev/null +++ b/dev_test_scripts/debug/debug_retro_pinball_arch_sweep.js @@ -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)) diff --git a/dev_test_scripts/debug/debug_retro_pinball_launch.js b/dev_test_scripts/debug/debug_retro_pinball_launch.js new file mode 100644 index 0000000..2e16e0f --- /dev/null +++ b/dev_test_scripts/debug/debug_retro_pinball_launch.js @@ -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) + ')') diff --git a/dev_test_scripts/integration/test_retro_pinball_integration.js b/dev_test_scripts/integration/test_retro_pinball_integration.js new file mode 100644 index 0000000..e5c1d1f --- /dev/null +++ b/dev_test_scripts/integration/test_retro_pinball_integration.js @@ -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); diff --git a/dev_test_scripts/unit/test_four_dim_polytopes.js b/dev_test_scripts/unit/test_four_dim_polytopes.js new file mode 100644 index 0000000..35f8ced --- /dev/null +++ b/dev_test_scripts/unit/test_four_dim_polytopes.js @@ -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) diff --git a/dev_test_scripts/unit/test_retro_pinball_physics.js b/dev_test_scripts/unit/test_retro_pinball_physics.js new file mode 100644 index 0000000..26478db --- /dev/null +++ b/dev_test_scripts/unit/test_retro_pinball_physics.js @@ -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); diff --git a/dev_test_scripts/unit/test_three_body_integrator.js b/dev_test_scripts/unit/test_three_body_integrator.js new file mode 100644 index 0000000..0065701 --- /dev/null +++ b/dev_test_scripts/unit/test_three_body_integrator.js @@ -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) diff --git a/public/tools/private_clipboard/clipboard.js b/public/tools/private_clipboard/clipboard.js index 5fa3dd4..9445b40 100644 --- a/public/tools/private_clipboard/clipboard.js +++ b/public/tools/private_clipboard/clipboard.js @@ -77,7 +77,26 @@ const setStatus = (text, type) => { el.className = 'pc-status' + (type ? ` ${type}` : '') } -const setProgress = (show, done, total) => { +// 鉴权失效时统一回到登录界面,避免“贴和传没有响应”的假死体验 +const showAuthScreen = (message) => { + const auth = $('pc-auth') + const app = $('pc-app') + if (app) app.style.display = 'none' + if (auth) auth.style.display = '' + const status = $('pc-auth-status') + if (status && message) { + status.textContent = message + status.className = 'pc-auth-status' + status.style.display = '' + } +} + +const notifyUnauthorized = () => { + showAuthScreen('登录状态已失效,请重新登录;若仍无法使用,请从首页九宫格重新进入') +} + +// byteInfo: { loaded, total } 当前文件的字节进度,用于大文件上传时实时更新进度条 +const setProgress = (show, done, total, byteInfo) => { const box = $('pc-progress') const text = $('pc-progress-text') const percent = $('pc-progress-percent') @@ -92,9 +111,17 @@ const setProgress = (show, done, total) => { } const safeTotal = Math.max(1, Number(total || 0)) const safeDone = Math.min(safeTotal, Math.max(0, Number(done || 0))) - const pct = Math.round((safeDone / safeTotal) * 100) + let ratio = safeDone / safeTotal + let label = `上传进度 ${safeDone}/${safeTotal}` + if (byteInfo && Number(byteInfo.total) > 0) { + const totalBytes = Number(byteInfo.total) + const loaded = Math.min(totalBytes, Math.max(0, Number(byteInfo.loaded || 0))) + ratio = (safeDone + loaded / totalBytes) / safeTotal + label = `上传进度 ${safeDone}/${safeTotal} · ${fmtSize(loaded)} / ${fmtSize(totalBytes)}` + } + const pct = Math.min(100, Math.round(ratio * 100)) box.className = 'pc-progress show' - text.textContent = `上传进度 ${safeDone}/${safeTotal}` + text.textContent = label percent.textContent = `${pct}%` fill.style.width = `${pct}%` } @@ -179,9 +206,12 @@ const stepViewer = (delta) => { const apiJson = async (url, options) => { const res = await fetch(url, options || {}) + if (res.status === 401) { + notifyUnauthorized() + throw new Error('unauthorized') + } const contentType = res.headers.get('content-type') || '' if (!contentType.includes('application/json')) { - if (res.status === 401) throw new Error('unauthorized') throw new Error(`unexpected_response_${res.status}`) } const data = await res.json() @@ -370,7 +400,9 @@ const uploadFiles = async (files, { uploadingText = '正在上传...', successMe try { let done = 0 for (const file of list) { - await uploadFile(file, groupId) + await uploadFile(file, groupId, (loaded, totalBytes) => { + setProgress(true, done, list.length, { loaded, total: totalBytes }) + }) done += 1 setProgress(true, done, list.length) } @@ -704,6 +736,10 @@ const saveText = async (text) => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) }) + if (res.status === 401) { + notifyUnauthorized() + throw new Error('unauthorized') + } const data = await res.json() if (!data || !data.ok) throw new Error((data && data.error) || 'save_failed') } @@ -716,29 +752,68 @@ const resolveFileName = (file) => { return `clipboard-${Date.now()}${suffix}` } -const uploadFile = async (file, groupId) => { - if (!file) return +// 使用 XHR 上传,利用 upload.onprogress 获取字节级进度(fetch 不支持上传进度事件) +const uploadFile = (file, groupId, onProgress) => new Promise((resolve, reject) => { + if (!file) { resolve(); return } const name = resolveFileName(file) - const buf = await file.arrayBuffer() const groupPart = Number.isFinite(groupId) ? `&group_id=${encodeURIComponent(String(groupId))}` : '' const url = `${apiBase}/upload?filename=${encodeURIComponent(name)}&mime=${encodeURIComponent(file.type || 'application/octet-stream')}${groupPart}` - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/octet-stream' }, - body: new Uint8Array(buf) - }) - const data = await res.json() - if (!data || !data.ok) throw new Error((data && data.error) || 'upload_failed') -} + const xhr = new XMLHttpRequest() + xhr.open('POST', url) + xhr.setRequestHeader('Content-Type', 'application/octet-stream') + if (xhr.upload && typeof onProgress === 'function') { + xhr.upload.addEventListener('progress', (e) => { + if (e && e.lengthComputable) onProgress(e.loaded, e.total) + }) + } + xhr.onload = () => { + let data = null + try { data = JSON.parse(xhr.responseText || '') } catch {} + if (xhr.status === 401) { + notifyUnauthorized() + reject(new Error('unauthorized')) + return + } + if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) { + resolve(data) + return + } + reject(new Error((data && data.error) || `upload_failed_${xhr.status}`)) + } + xhr.onerror = () => reject(new Error('upload_network_error')) + xhr.onabort = () => reject(new Error('upload_aborted')) + xhr.send(file) +}) const deleteItem = async (id) => { if (!id) return const res = await fetch(`${apiBase}/${id}`, { method: 'DELETE' }) + if (res.status === 401) { + notifyUnauthorized() + throw new Error('unauthorized') + } const data = await res.json() if (!data || !data.ok) throw new Error((data && data.error) || 'delete_failed') await fetchList() } +// 某些粘贴来源下 getAsString 回调可能永不触发,加超时保护避免“正在保存...”假死 +const getStringFromItem = (item, timeoutMs) => new Promise((resolve) => { + let settled = false + const timer = setTimeout(() => finish(null), timeoutMs || 1500) + const finish = (value) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(value) + } + try { + item.getAsString(text => finish(text)) + } catch { + finish(null) + } +}) + const handlePaste = async (event) => { if (!isQuickEntryMode()) return const data = event.clipboardData @@ -757,31 +832,28 @@ const handlePaste = async (event) => { setProgress(true, 0, files.length) let done = 0 for (const f of files) { - await uploadFile(f, groupId) + await uploadFile(f, groupId, (loaded, totalBytes) => { + setProgress(true, done, files.length, { loaded, total: totalBytes }) + }) done += 1 setProgress(true, done, files.length) } } for (const s of strings) { - await new Promise((resolve) => { - s.getAsString(async (text) => { - try { - const v = String(text || '').trim() - if (v) await saveText(v) - } catch (err) { - stringSaveError = err - } finally { - resolve() - } - }) - }) + try { + const text = await getStringFromItem(s, 1500) + const v = String(text || '').trim() + if (v) await saveText(v) + } catch (err) { + stringSaveError = err + } } if (stringSaveError) throw stringSaveError await refreshCurrentView({ successMessage: '已保存到服务器' }) - setProgress(false) } catch (e) { - setProgress(false) setStatus('保存失败', 'error') + } finally { + setProgress(false) } } @@ -822,7 +894,11 @@ const bindPasteInput = () => { input.select() } if (btn) btn.addEventListener('click', focusInput) - input.addEventListener('paste', handlePaste) + // 阻止冒泡到 document 级 paste 监听,避免同一次粘贴被处理两遍 + input.addEventListener('paste', (event) => { + event.stopPropagation() + handlePaste(event) + }) const handleInput = async () => { if (!isQuickEntryMode()) return const value = String(input.value || '').trim() @@ -1065,6 +1141,12 @@ const init = async () => { // 先检查是否已登录 try { const res = await fetch(apiBase + '/auth/me', { credentials: 'same-origin', cache: 'no-store' }) + if (res.status === 401) { + // 外层授权或登录态失效:回到登录界面并尝试自动填充 + showAuthScreen('访问授权或登录已失效,请重新登录') + await tryAutoFill() + return + } const data = await res.json() if (data && data.ok) { $auth.style.display = 'none' @@ -1078,7 +1160,16 @@ const init = async () => { await tryAutoFill() } +let appReady = false const initApp = async () => { + // 登录态失效后重新登录时,避免重复绑定事件,只刷新数据 + if (appReady) { + try { + await Promise.all([fetchFolders(), fetchList()]) + } catch {} + return + } + appReady = true clearUrlQuery() const isMobile = detectMobile() let keywordTimer = null diff --git a/public/tools/thought_lab/labs/four_dim_space/index.html b/public/tools/thought_lab/labs/four_dim_space/index.html new file mode 100644 index 0000000..7b00240 --- /dev/null +++ b/public/tools/thought_lab/labs/four_dim_space/index.html @@ -0,0 +1,539 @@ + + + +
+ + +从 0 维到 4 维,「超立方体家族」的计数有严格公式:顶点 = 2ⁿ,边 = n·2ⁿ⁻¹,正方形面 = C(n,2)·2ⁿ⁻²,立方体胞 = C(n,3)·2ⁿ⁻³。下表数值全部由公式算出:
+| 维度 | 名称 | 顶点 | 边 | 正方形面 | 立方体胞 |
|---|---|---|---|---|---|
| 0 | 点 | 1 | — | — | — |
| 1 | 线段 | 2 | 1 | — | — |
| 2 | 正方形 | 4 | 4 | 1 | — |
| 3 | 立方体 | 8 | 12 | 6 | 1 |
| 4 | 超立方体 | 16 | 32 | 24 | 8 |
为什么 4D 特别?凸正多胞体(各维的「正多面体」)的个数:2D 有无穷多种(正 n 边形),3D 有 5 种(柏拉图立体),4D 有 6 种(本 lab 渲染其中 5 种),而 5 维及以上只剩 3 种。第四维是正多胞体最「丰富」的维度。
+两种认知手段(Flatland 类比):一个生活在二维平面里的正方形,无法直接看到三维立方体,但它可以 (1) 看立方体在平面上的投影(影子),(2) 观察立方体穿过平面时的切片(一段段变化的线段)。我们对第四维做同样的事:投影页签 = 手段 1,切片页签 = 手段 2。
+旋转平面数:n 维空间的独立旋转平面数 = n(n−1)/2。3D 有 3 个(XY/XZ/YZ),4D 有 6 个——多出的 XW/YW/ZW 三个平面正是投影页签里「4D 特有」的那组滑块。
+可视化第四个空间维——一个与 x/y/z 都垂直的纯数学方向。所有几何对象(正多胞体坐标、超球切片半径)都是严格数学,顶点数/边数由代码实算并经单元测试校验,不是示意性的瞎画。
+迄今所有观测都表明宏观空间是 3 维的。额外空间维只出现在卡鲁扎-克莱因理论与弦论中,且被紧致化在普朗克尺度(约 1.6×10⁻³⁵ m),目前没有任何直接观测证据。本 lab 是数学可视化工具,用于理解「如果存在第四空间维,几何会怎样」,不宣称宇宙真的如此。
+相对论的「四维」是 3 维空间 + 1 维时间(闵可夫斯基时空,时间维带负号度规),见本实验室的「闵可夫斯基画板」。本 lab 的第四维是空间维(正定度规),两者数学结构不同,不要混淆。
+两级透视投影:4D→3D(相机在 w 轴距离 d 处,s = d/(d−w)),再 3D→2D。w 越接近相机,投影越大——正如超立方体投影里「外面的大立方体」其实离我们在第四维上更近。颜色编码 w 坐标:暖色 = w>0,冷色 = w<0。
+4D 特有现象:两个完全正交的平面(如 XY 与 ZW)可以同时以相同角速度旋转、互不干扰,称为等倾(双)旋转。3D 中任何旋转都只有一个旋转平面,做不到这一点。
+不是教你天天下单,而是帮你看清:钱、机会、性格、纪律,彼此到底是什么关系。
+现金,本质上是未来的期权。
+很多人一有仓位就手痒,一见波动就想证明自己没看错,一有利润就怕飞,一有亏损就不肯认。最后看起来是在研究市场,实际上是在被自己的贪婪、恐惧、赌性和执念来回牵着走。
+这页不是做行情预测,也不是鼓动高频交易。它更像一间培训教室,把常见的人性弱点摊开来讲,把常见的投资风格讲成人能听懂的话:谁适合什么路子,谁为什么总在错误的时间做了错误的事,为什么真正的大机会往往不是“盯出来”的,而是“等出来”的。
+很多培训里最容易被忽略的一点是:空仓和轻仓,不等于“什么都没做”。有时候,最重要的动作,恰恰是不动。
+你手里的现金,像一张还没使用的门票。它让你在真正好的机会来时能进场,也让你在烂机会很多的时候还能站在门外。你现在把钱打光了,等于把未来的主动权也一并卖掉了。
+现金本身不一定高收益,但它有流动性、有安全垫、有再配置能力。市场机会分布通常并不均匀,尤其在高波动资产里,“值得大举出手”的时点,往往远少于“让人手痒但没必要动”的时点。
+所以,一个成熟投资者最先学会的,不是怎么每天交易,而是怎么分辨:这次波动,到底只是噪音,还是值得动真格的机会? 一年里真正有质量的大机会,很多时候确实没有几次。大部分时间,市场给你的不是红包,而是诱惑。
+因为普通波动最会制造“好像有机会”的错觉。它不给你特别高的胜率,也不给你特别舒服的赔率,却足够频繁,足够让人手痒。很多账户不是死在一次巨大判断失误上,而是死在几十次看起来都不算太离谱的小动作上。每次都不严重,累积起来却把耐心、仓位和心态一起掏空了。
+可以这样讲:等待不是坐着发呆,而是在做筛选。像猎人伏在草丛里,不是因为懒,而是因为他知道,不是每一阵风吹草动都值得扣动扳机。真正有效的等待,背后其实有标准、有判断、有忍耐,而不是空想。
+同样是做股票,不同的人其实在走完全不同的路。路没有绝对高低,但有适不适合。关键不是“哪种最牛”,而是“哪种和你的性格、时间、能力圈匹不匹配”。
+培训时可以直接讲清楚一件事:风格不是勋章,是适配。 上班很忙的人,不适合硬学高频节奏;情绪不稳的人,不适合天天追龙头;没有复盘习惯的人,做系统也很容易沦为口号。真正高效的培训,不是让每个人都学同一招,而是先帮他认清自己适合在哪个战场打仗。
+很多人一上来就问:“哪种风格最赚钱?” 这个问题本身就容易误导。更应该先问的是:“哪种风格最不容易把我做废?” 因为如果风格和性格不匹配,再好的方法也会在执行时变形。
+你看到别人做龙头赚钱,不代表你也适合追最强;你看到别人长期持有赚钱,也不代表你能承受三个月不看分时。真正有效的学习,不是照抄招式,而是先认识自己的时间结构、情绪结构和认知结构。
+很多操作表面上像是在判断市场,实质上是在暴露性格。追高、杀跌、扛亏、梭哈,背后往往不是技术问题,而是人的问题。
+真正让人冲进去的,很多时候不是逻辑,而是“别人都赚钱了,我不能落下”。这不是研究之后的果断,而是 FOMO 在控制手指。
+该止损时不止,等到情绪崩的时候才砍;很多人不是止损不及时,而是止损标准从来没提前定过。
+重仓不是绝对错误,但“随便一次想法就满仓”通常说明,你不是在配置风险,而是在赌自己这次肯定对。
+很多深套并不是市场突然背刺,而是人在最早应该认错的时候,选择了拖、熬、骗自己“再等等就回来”。
+所以培训里最值得反复讲的一句话是:每一次操作,既是在和市场交手,也是在和自己交手。 市场不负责迁就你的性格,账户也不会替你隐藏缺点。你是急、是贪、是倔、是赌,它都会最后体现在曲线上。
+因为等待很无聊,承认错误很难,什么都不做又让人没有存在感。于是人会下意识地通过“不断交易”来证明自己在掌控局面。实际上,这恰恰是最容易被市场牵着走的时候。
+不是从此百战百胜,而是开始接受:错过没关系,踏空没关系,卖飞一点也没关系。只要大错少犯,账户就会开始从“情绪日记”变成“纪律成果”。
+最常见的话是“再等等,也许就回来了”。它看起来像希望,实质上往往是在逃避决策。侥幸最大的伤害,不是让你错过最佳处理点,而是让你习惯把问题往后拖。
+很多人加仓不是因为机会变好了,而是因为想证明自己一开始没错。可市场并不负责给谁面子。你越急着证明,越容易把一次错误判断变成一场持久战。
+别人涨停、别人翻倍、别人晒单,这些画面最容易把人的节奏打乱。比较一旦成为驱动力,交易就会越来越像赶路,而不是按自己的节奏走路。
+培训里还可以把“人性”讲得更直白一点:人最容易在两种情况下犯错。 一种是太兴奋的时候,以为自己无所不能;另一种是太难受的时候,想赶紧做点什么缓解痛苦。可市场从不因为你难受就给你低风险机会,也不因为你自信就送你确定利润。
+很多人只学买点、卖点,却不学节奏。可真正决定长期结果的,往往是节奏有没有完整。
+先等市场把条件走出来,而不是自己在脑子里提前下结论。好交易通常是“信号到了我才动”,而不是“我觉得要涨所以先埋伏”。
+不是所有机会都值得重仓。把机会分级,本质上是在给资金安排轻重缓急。普通机会轻仓试,强机会再上强度。
+成熟的人会先写“退出条件”,再写“盈利预期”。因为真正能把账户拖垮的,往往不是少赚,而是大错不认。
+判断对方向只是开始,把纸面盈利变成真实盈利,靠的是兑现能力。见好就收,不是保守,而是尊重市场的不确定性。
+复盘的目的不是骂自己,也不是证明自己其实早就知道,而是把每次盈亏拆成“哪里对了、哪里错了、以后怎么少犯”。
+同样的判断,对错都可能存在;但不同仓位,带来的心理压力和账户波动完全不同。仓位一大,人的解释欲、侥幸心和临场冲动都会被放大。所以很多人不是分析错了,而是仓位先把心态压垮了。培训时一定要讲清楚:仓位管理不是附属动作,它本身就是交易系统的一部分。
+不是因为不知道“应该止损”,而是因为止损在情绪上像承认自己失败。但成熟的交易者会慢慢理解:止损不是人格否定,它只是一次业务处理。把错误限制在可承受范围内,本身就是专业的一部分。
+下面这些不是荐股模板,而是可直接拿来培训的“情景化说法”。讲人的行为,比单讲术语更容易让人记住。
+可以这样讲:你以为自己买的是强势,其实很多时候买的是情绪最热的那一分钟。此时最需要问的不是“还能不能涨”,而是“我是不是因为怕错过才冲进去”。
+可以这样讲:补仓不是爱,补仓是二次决策。第一次买错了,第二次更应该先问逻辑还在不在,而不是因为账面难看就继续往里填。
+可以这样讲:很多人不是不会买,是不会收。总想卖在最高点,最后常常把已经到手的利润又看回去。账户不是比谁卖得最神,而是比谁真正带走了钱。
+可以这样讲:市场不按出勤率发工资。你忙,说明你很投入;但交易世界里,投入不等于有效。真正值钱的是高质量出手,而不是高频存在感。
+可以这样讲:这不是技术问题,是情绪需要。很多人不是看到了机会才交易,而是因为焦虑、无聊、想扳回一局,所以找机会交易。
+可以这样讲:踏空很难受,但踏空不致命,乱追才致命。市场最喜欢利用人的比较心。只要你为了不落后而失去节奏,后面就容易越做越乱。
+先讲现金,告诉他“不是天天都要出手”;再讲风格,让他知道“不是每条路都适合自己”;再讲人性,让他明白“真正的敌人常常在自己心里”;最后讲节奏,告诉他“市场不按勤奋发工资,而按纪律给结果”。这样一条线下来,培训对象会更容易把零散概念串成一个整体。
+这部分不是“谁说了就一定对”,而是告诉培训对象:很多成熟投资观念,前人早就反复强调过。这里不做生硬语录堆砌,只给出可查的名字和来源。
+常被视为价值投资重要奠基者之一。关于“安全边际”“市场先生”等观念,核心不是让人死守某只股票,而是提醒人:市场价格经常带情绪,投资者要学会区分价格与价值。
+他的很多公开备忘录与《周期》都在反复强调一件事:市场机会不是均匀分布的,风险偏好、估值和周期位置会变。投资者的重要能力之一,是在机会差的时候克制,在赔率好的时候出手。
+他长期强调理性、耐心、能力圈和少犯大错。培训里借用他的思路时,重点不在模仿口头风格,而在理解:真正高质量的决策,很多时候来自等待少数足够好的球,而不是什么都想挥棒。
+市场人物与交易故事经常被拿来讲“顺势”“等待”“人性反复”。但培训时要提醒:关于他的很多流传名句,网上版本混杂,适合引用其思想脉络,不宜把所有热门短句都当成原文。
+这是一种培训中常用的通俗比喻,意思是强调现金所代表的未来选择权、流动性和主动性,并不是在说现金真的等同于金融学定义中的标准化期权合约。培训使用时,最好把这个边界讲清楚,避免概念混淆。
+本页讲的是投资认知、风格适配与行为纪律,不涉及个股推荐、收益承诺或短线预测。培训落地时,也建议始终提醒听众:方法再好,也要和风险承受能力、资金来源、投资期限相匹配。
+如果要把这整页浓缩成一句适合结尾的话,可以这样讲:
+ 炒股不是拼天赋,更像一场修行。 你会不会等,会不会认错,会不会克制,会不会感恩已经到手的利润,这些东西比“哪根均线金叉”更决定你的长期结果。
+ 当你不再把每一次波动都当成机会,不再把每一次回撤都当成耻辱,不再把每一次盈利都想榨到最后一滴,你才算真正从“情绪交易”走向“成熟投资”。
总得分 0
+ +修改布局参数会重新开局。
+勾选拨片后两根摆杆如雨刮器刮动;再勾「转动」改为绕轴连续旋转(两杆反向)。随时开关、不重开局。
+ +dt=1/360s,高速发射不穿透薄木条。j=-(1+e)·v_n/m⁻¹,低速接触时 e=0 防抖动。|j_t| ≤ μ·j_n 钳制,同时驱动角速度,弹珠会真实地「滚动」而非滑动。g·sinθ,θ 即倾角滑块。一场基于物理与想象的第一人称视觉实验
+ +