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 @@ + + + + + + +四维空间实验室 · 正多胞体 / 超球切片 / 维度类比 + + + +
+

💠 四维空间实验室

+ 第四空间维的数学可视化 · 科学教研 + +
+ +
+ + + +
+ +
+
+ +
+ + + + + +
+ +

这个 lab 在做什么

+

可视化第四个空间维——一个与 x/y/z 都垂直的纯数学方向。所有几何对象(正多胞体坐标、超球切片半径)都是严格数学,顶点数/边数由代码实算并经单元测试校验,不是示意性的瞎画。

+

科学边界(重要)

+

迄今所有观测都表明宏观空间是 3 维的。额外空间维只出现在卡鲁扎-克莱因理论与弦论中,且被紧致化在普朗克尺度(约 1.6×10⁻³⁵ m),目前没有任何直接观测证据。本 lab 是数学可视化工具,用于理解「如果存在第四空间维,几何会怎样」,不宣称宇宙真的如此。

+

与时空 4D 的区别

+

相对论的「四维」是 3 维空间 + 1 维时间(闵可夫斯基时空,时间维带负号度规),见本实验室的「闵可夫斯基画板」。本 lab 的第四维是空间维(正定度规),两者数学结构不同,不要混淆。

+

投影方法

+

两级透视投影:4D→3D(相机在 w 轴距离 d 处,s = d/(d−w)),再 3D→2D。w 越接近相机,投影越大——正如超立方体投影里「外面的大立方体」其实离我们在第四维上更近。颜色编码 w 坐标:暖色 = w>0,冷色 = w<0。

+

等倾旋转

+

4D 特有现象:两个完全正交的平面(如 XY 与 ZW)可以同时以相同角速度旋转、互不干扰,称为等倾(双)旋转。3D 中任何旋转都只有一个旋转平面,做不到这一点。

+
+ + + + diff --git a/public/tools/thought_lab/labs/humanity_and_investing/index.html b/public/tools/thought_lab/labs/humanity_and_investing/index.html new file mode 100644 index 0000000..8bfd5b8 --- /dev/null +++ b/public/tools/thought_lab/labs/humanity_and_investing/index.html @@ -0,0 +1,1177 @@ + + + + + +思想实验室 · 人性与投资 + + + + + +
+
+
+
Thought Lab
+

思想实验室 · 人性与投资

+

不是教你天天下单,而是帮你看清:钱、机会、性格、纪律,彼此到底是什么关系。

+
+ +
+ +
+
+
Training Web Lab
+

炒股不是比谁更急,
而是比谁更知道
什么时候该动,什么时候该等。

+

现金,本质上是未来的期权。

+

很多人一有仓位就手痒,一见波动就想证明自己没看错,一有利润就怕飞,一有亏损就不肯认。最后看起来是在研究市场,实际上是在被自己的贪婪、恐惧、赌性和执念来回牵着走。

+

这页不是做行情预测,也不是鼓动高频交易。它更像一间培训教室,把常见的人性弱点摊开来讲,把常见的投资风格讲成人能听懂的话:谁适合什么路子,谁为什么总在错误的时间做了错误的事,为什么真正的大机会往往不是“盯出来”的,而是“等出来”的。

+
+
+ 等待 + 不是没本事,是不把子弹浪费在普通波动里。 +
+
+ 纪律 + 不是冷血,是事先决定,临场执行,不让情绪接管账户。 +
+
+ 复盘 + 不是后悔,是把每一次盈亏都变成下一次少犯错的资本。 +
+
+
+ +
+ +
+ + +
+
+
+
+
Section 01
+

现金,不只是钱,还是未来的选择权

+

很多培训里最容易被忽略的一点是:空仓和轻仓,不等于“什么都没做”。有时候,最重要的动作,恰恰是不动。

+
+
+
+ 通俗一点说 +

你手里的现金,像一张还没使用的门票。它让你在真正好的机会来时能进场,也让你在烂机会很多的时候还能站在门外。你现在把钱打光了,等于把未来的主动权也一并卖掉了。

+
+
+ 金融常识上怎么理解 +

现金本身不一定高收益,但它有流动性、有安全垫、有再配置能力。市场机会分布通常并不均匀,尤其在高波动资产里,“值得大举出手”的时点,往往远少于“让人手痒但没必要动”的时点。

+
+
+

所以,一个成熟投资者最先学会的,不是怎么每天交易,而是怎么分辨:这次波动,到底只是噪音,还是值得动真格的机会? 一年里真正有质量的大机会,很多时候确实没有几次。大部分时间,市场给你的不是红包,而是诱惑。

+
+
+

错误理解

+
    +
  • 手里有钱就难受,总想找个地方放进去。
  • +
  • 看到别人赚钱,怕错过,于是把等待误解为落后。
  • +
  • 把“满仓参与”当成敬业,把“轻仓观察”当成胆小。
  • +
+
+
+

正确理解

+
    +
  • 轻仓,是给未来保留出手权。
  • +
  • 空仓,是在保护判断力不被噪音拖走。
  • +
  • 等,不是消极,而是在筛选确定性。
  • +
+
+
+

培训一句话

+
    +
  • 不是每一次市场波动都配得上你的资金。
  • +
  • 真正厉害的人,往往不是天天动的人,而是关键时刻动得够准的人。
  • +
+
+
+
+
+ 为什么“普通波动”最容易吃掉你的弹药 +

因为普通波动最会制造“好像有机会”的错觉。它不给你特别高的胜率,也不给你特别舒服的赔率,却足够频繁,足够让人手痒。很多账户不是死在一次巨大判断失误上,而是死在几十次看起来都不算太离谱的小动作上。每次都不严重,累积起来却把耐心、仓位和心态一起掏空了。

+
+
+ 培训时可以怎么解释“等” +

可以这样讲:等待不是坐着发呆,而是在做筛选。像猎人伏在草丛里,不是因为懒,而是因为他知道,不是每一阵风吹草动都值得扣动扳机。真正有效的等待,背后其实有标准、有判断、有忍耐,而不是空想。

+
+
+
+
+

轻仓最有用的三个时刻

+
    +
  • 看法刚形成,但还没被市场验证的时候。
  • +
  • 环境很吵、板块轮动快、胜率不清楚的时候。
  • +
  • 自己状态一般、情绪波动明显的时候。
  • +
+
+
+

重仓真正适合的前提

+
    +
  • 机会级别明显高于平时,不是“差不多”。
  • +
  • 退出计划已经想清楚,不是先上车再说。
  • +
  • 这笔交易即使出错,也不会把整个账户节奏打崩。
  • +
+
+
+

一句适合培训的提醒

+
    +
  • 现金不是闲着,是在值班。
  • +
  • 仓位不是勇气证明,而是机会等级的翻译器。
  • +
+
+
+
+
+ +
+
+
+
Section 02
+

六种常见风格,本质上是六种性格

+

同样是做股票,不同的人其实在走完全不同的路。路没有绝对高低,但有适不适合。关键不是“哪种最牛”,而是“哪种和你的性格、时间、能力圈匹不匹配”。

+
+
+
+ 1 · 猎人型 +

等大机会,不做无效开火

+
    +
  • 特点:一年出手不多,宁愿错过,也不愿凑合。
  • +
  • 优点:交易次数少,更容易把精力留给真正有把握的节点。
  • +
  • 风险:如果没有真本事,容易把“不会做”伪装成“我在等”。
  • +
  • 一句白话:不开枪则已,一开枪就要先想好怎么带着利润离场。
  • +
+
+
+ 2 · 守株型 +

认准企业,愿意熬时间

+
    +
  • 特点:看趋势、看基本面、看行业位置,不太看分时波动。
  • +
  • 优点:能避免频繁交易带来的情绪消耗和手续费消耗。
  • +
  • 风险:容易把“长期”误用成“死扛”,把逻辑变了的票也拿很久。
  • +
  • 一句白话:拿三五年可以,但前提是逻辑还在,不是嘴上长期、心里侥幸。
  • +
+
+
+ 3 · 工匠型 +

只做熟票,反复磨成本

+
    +
  • 特点:围绕熟悉的标的做波段、做 T、做节奏。
  • +
  • 优点:对个股脾气更熟,更容易形成自己的“手感数据库”。
  • +
  • 风险:过度自信后,容易在趋势反转时还以为只是正常震荡。
  • +
  • 一句白话:把成本磨低很厉害,但前提是你磨的是玉,不是石头。
  • +
+
+
+ 4 · 龙头型 +

只做主线,只做最强

+
    +
  • 特点:聚焦市场主线题材,只盯辨识度最高、资金最集中的票。
  • +
  • 优点:一旦节奏对,弹性通常比杂毛更高,也更容易形成统一预期。
  • +
  • 风险:最强股波动也最大,没有纪律时,回撤同样会最凶。
  • +
  • 一句白话:这条路拼的不是胆子大,而是复盘够深、执行够快。
  • +
+
+
+ 5 · 系统型 +

规则先行,信号说了算

+
    +
  • 特点:提前写好买卖规则,到点执行,不靠临场灵感。
  • +
  • 优点:能减少情绪化操作,让账户更可复盘、可检验。
  • +
  • 风险:系统若没有经过足够样本检验,机械执行也可能机械亏损。
  • +
  • 一句白话:不猜新闻、不追故事,先问规则有没有给你绿灯。
  • +
+
+
+ 6 · 清醒型 +

不贪最后一口,知道见好就收

+
    +
  • 特点:不执着卖在最高点,重视兑现,重视盈亏比。
  • +
  • 优点:能把纸面盈利尽量变成真实收益,不被“再等等”拖回原地。
  • +
  • 风险:如果过早离场,也可能总是赚小钱、错过大波段。
  • +
  • 一句白话:会卖的人,不一定卖得最顶,但通常活得更久。
  • +
+
+
+

培训时可以直接讲清楚一件事:风格不是勋章,是适配。 上班很忙的人,不适合硬学高频节奏;情绪不稳的人,不适合天天追龙头;没有复盘习惯的人,做系统也很容易沦为口号。真正高效的培训,不是让每个人都学同一招,而是先帮他认清自己适合在哪个战场打仗。

+
+
+

时间少的人

+
    +
  • 更适合守株型、部分系统型。
  • +
  • 不适合盯盘依赖太强的打法。
  • +
  • 重点在减少动作,而不是增加技巧。
  • +
+
+
+

情绪起伏大的人

+
    +
  • 更适合规则明确、频率较低的风格。
  • +
  • 不适合“最强、最快、最刺激”的战场。
  • +
  • 先把节奏放慢,往往比学新战法更重要。
  • +
+
+
+

研究能力强的人

+
    +
  • 更适合守株型、猎人型、部分龙头型。
  • +
  • 但也容易掉进“研究越多越舍不得认错”的坑。
  • +
  • 研究能力越强,越要配退出纪律。
  • +
+
+
+
+
+ 最常见的培训误区 +

很多人一上来就问:“哪种风格最赚钱?” 这个问题本身就容易误导。更应该先问的是:“哪种风格最不容易把我做废?” 因为如果风格和性格不匹配,再好的方法也会在执行时变形。

+
+
+ 适配,比模仿重要 +

你看到别人做龙头赚钱,不代表你也适合追最强;你看到别人长期持有赚钱,也不代表你能承受三个月不看分时。真正有效的学习,不是照抄招式,而是先认识自己的时间结构、情绪结构和认知结构。

+
+
+
+
+ +
+
+
+
Section 03
+

账户就是镜子:你怎么交易,就怎么暴露自己

+

很多操作表面上像是在判断市场,实质上是在暴露性格。追高、杀跌、扛亏、梭哈,背后往往不是技术问题,而是人的问题。

+
+
+
+ 追高 + 常见底色:贪婪 + 怕错过 +

真正让人冲进去的,很多时候不是逻辑,而是“别人都赚钱了,我不能落下”。这不是研究之后的果断,而是 FOMO 在控制手指。

+
+
+ 杀跌 + 常见底色:恐惧 + 无计划 +

该止损时不止,等到情绪崩的时候才砍;很多人不是止损不及时,而是止损标准从来没提前定过。

+
+
+ 满仓 + 常见底色:赌性 + 过度自信 +

重仓不是绝对错误,但“随便一次想法就满仓”通常说明,你不是在配置风险,而是在赌自己这次肯定对。

+
+
+ 深套 + 常见底色:不愿承认失败 +

很多深套并不是市场突然背刺,而是人在最早应该认错的时候,选择了拖、熬、骗自己“再等等就回来”。

+
+
+

所以培训里最值得反复讲的一句话是:每一次操作,既是在和市场交手,也是在和自己交手。 市场不负责迁就你的性格,账户也不会替你隐藏缺点。你是急、是贪、是倔、是赌,它都会最后体现在曲线上。

+
+
+ 为什么有人总在“错的时候特别勤奋” +

因为等待很无聊,承认错误很难,什么都不做又让人没有存在感。于是人会下意识地通过“不断交易”来证明自己在掌控局面。实际上,这恰恰是最容易被市场牵着走的时候。

+
+
+ 真正成熟的变化是什么 +

不是从此百战百胜,而是开始接受:错过没关系,踏空没关系,卖飞一点也没关系。只要大错少犯,账户就会开始从“情绪日记”变成“纪律成果”。

+
+
+
+
+

侥幸心理

+

最常见的话是“再等等,也许就回来了”。它看起来像希望,实质上往往是在逃避决策。侥幸最大的伤害,不是让你错过最佳处理点,而是让你习惯把问题往后拖。

+
+
+

证明心理

+

很多人加仓不是因为机会变好了,而是因为想证明自己一开始没错。可市场并不负责给谁面子。你越急着证明,越容易把一次错误判断变成一场持久战。

+
+
+

比较心理

+

别人涨停、别人翻倍、别人晒单,这些画面最容易把人的节奏打乱。比较一旦成为驱动力,交易就会越来越像赶路,而不是按自己的节奏走路。

+
+
+

培训里还可以把“人性”讲得更直白一点:人最容易在两种情况下犯错。 一种是太兴奋的时候,以为自己无所不能;另一种是太难受的时候,想赶紧做点什么缓解痛苦。可市场从不因为你难受就给你低风险机会,也不因为你自信就送你确定利润。

+
+
+ +
+
+
+
Section 04
+

一笔成熟的交易,往往不是从买入开始,而是从等待开始

+

很多人只学买点、卖点,却不学节奏。可真正决定长期结果的,往往是节奏有没有完整。

+
+
+
+
01
+
+

先等,不先猜

+

先等市场把条件走出来,而不是自己在脑子里提前下结论。好交易通常是“信号到了我才动”,而不是“我觉得要涨所以先埋伏”。

+
+
+
+
02
+
+

看清机会等级

+

不是所有机会都值得重仓。把机会分级,本质上是在给资金安排轻重缓急。普通机会轻仓试,强机会再上强度。

+
+
+
+
03
+
+

先想错了怎么办

+

成熟的人会先写“退出条件”,再写“盈利预期”。因为真正能把账户拖垮的,往往不是少赚,而是大错不认。

+
+
+
+
04
+
+

赚到手,比看对更重要

+

判断对方向只是开始,把纸面盈利变成真实盈利,靠的是兑现能力。见好就收,不是保守,而是尊重市场的不确定性。

+
+
+
+
05
+
+

复盘,不是复仇

+

复盘的目的不是骂自己,也不是证明自己其实早就知道,而是把每次盈亏拆成“哪里对了、哪里错了、以后怎么少犯”。

+
+
+
+
+
+

培训可直接落地的四个纪律问题

+
    +
  • 这次机会属于普通、较强,还是极强?
  • +
  • 如果错了,我在哪里退出,不拖泥带水?
  • +
  • 如果对了,我准备分批兑现,还是趋势持有?
  • +
  • 这笔交易,事后能不能写成一页清楚的复盘?
  • +
+
+
+

一句适合培训反复讲的话

+
    +
  • 机会不是天天都好,账户也不是天天都该满。
  • +
  • 能长期活下来的人,往往不是猜得最准的人,而是节奏最稳的人。
  • +
  • 把错扛成灾难,通常不是因为市场太坏,而是因为人不愿认错。
  • +
+
+
+
+
+ 仓位为什么是节奏放大器 +

同样的判断,对错都可能存在;但不同仓位,带来的心理压力和账户波动完全不同。仓位一大,人的解释欲、侥幸心和临场冲动都会被放大。所以很多人不是分析错了,而是仓位先把心态压垮了。培训时一定要讲清楚:仓位管理不是附属动作,它本身就是交易系统的一部分。

+
+
+ 止损为什么难 +

不是因为不知道“应该止损”,而是因为止损在情绪上像承认自己失败。但成熟的交易者会慢慢理解:止损不是人格否定,它只是一次业务处理。把错误限制在可承受范围内,本身就是专业的一部分。

+
+
+
+
+

仓位轻时要做什么

+
    +
  • 观察市场是否认可自己的判断。
  • +
  • 记录进入理由是否仍在增强。
  • +
  • 防止自己因为“仓位轻”而乱加快节奏。
  • +
+
+
+

仓位重时要记住什么

+
    +
  • 仓位越重,越不能临场改规则。
  • +
  • 仓位越重,越要提前准备退出动作。
  • +
  • 仓位越重,越不能拿情绪当依据。
  • +
+
+
+

复盘可追问三件事

+
    +
  • 我是看对了,还是运气好?
  • +
  • 我是亏在判断,还是亏在执行?
  • +
  • 下次同样情形,我要删掉哪个坏动作?
  • +
+
+
+
+
+ +
+
+
+
Section 05
+

培训场景:把概念讲成人能立刻懂的画面

+

下面这些不是荐股模板,而是可直接拿来培训的“情景化说法”。讲人的行为,比单讲术语更容易让人记住。

+
+
+
+

场景 A:涨停后第二天冲进去

+

可以这样讲:你以为自己买的是强势,其实很多时候买的是情绪最热的那一分钟。此时最需要问的不是“还能不能涨”,而是“我是不是因为怕错过才冲进去”。

+
+
+

场景 B:跌一点就想补仓摊平

+

可以这样讲:补仓不是爱,补仓是二次决策。第一次买错了,第二次更应该先问逻辑还在不在,而不是因为账面难看就继续往里填。

+
+
+

场景 C:赚了 8% 不舍得走

+

可以这样讲:很多人不是不会买,是不会收。总想卖在最高点,最后常常把已经到手的利润又看回去。账户不是比谁卖得最神,而是比谁真正带走了钱。

+
+
+

场景 D:一年忙了很多,收益却一般

+

可以这样讲:市场不按出勤率发工资。你忙,说明你很投入;但交易世界里,投入不等于有效。真正值钱的是高质量出手,而不是高频存在感。

+
+
+

场景 E:明知不该做,还是手痒

+

可以这样讲:这不是技术问题,是情绪需要。很多人不是看到了机会才交易,而是因为焦虑、无聊、想扳回一局,所以找机会交易。

+
+
+

场景 F:别人赚钱了,自己踏空

+

可以这样讲:踏空很难受,但踏空不致命,乱追才致命。市场最喜欢利用人的比较心。只要你为了不落后而失去节奏,后面就容易越做越乱。

+
+
+
+
+

适合现场互动提问

+
    +
  • 你最近一次冲动交易,是因为机会真的好,还是因为自己不舒服?
  • +
  • 你最常犯的是追高、扛亏、频繁切换,还是拿不住利润?
  • +
  • 你现在的风格,是自己选的,还是被情绪推着形成的?
  • +
+
+
+

适合培训收口的三句话

+
    +
  • 真正伤账户的,往往不是一次看错,而是连续很多次乱动。
  • +
  • 交易不是证明自己聪明,而是保护自己别犯大错。
  • +
  • 能长期稳定的人,通常都先学会了少做。
  • +
+
+
+
+ 如果要带新人,可以这样串起来讲 +

先讲现金,告诉他“不是天天都要出手”;再讲风格,让他知道“不是每条路都适合自己”;再讲人性,让他明白“真正的敌人常常在自己心里”;最后讲节奏,告诉他“市场不按勤奋发工资,而按纪律给结果”。这样一条线下来,培训对象会更容易把零散概念串成一个整体。

+
+
+
+ +
+
+
+
Section 06
+

名人备注与来源

+

这部分不是“谁说了就一定对”,而是告诉培训对象:很多成熟投资观念,前人早就反复强调过。这里不做生硬语录堆砌,只给出可查的名字和来源。

+
+
+
+

本杰明·格雷厄姆

+

常被视为价值投资重要奠基者之一。关于“安全边际”“市场先生”等观念,核心不是让人死守某只股票,而是提醒人:市场价格经常带情绪,投资者要学会区分价格与价值。

+
来源:Benjamin Graham,《The Intelligent Investor》(《聪明的投资者》)。
+
+
+

霍华德·马克斯

+

他的很多公开备忘录与《周期》都在反复强调一件事:市场机会不是均匀分布的,风险偏好、估值和周期位置会变。投资者的重要能力之一,是在机会差的时候克制,在赔率好的时候出手。

+
来源:Howard Marks,《Mastering the Market Cycle》(《周期》);Oaktree Capital 公开备忘录。
+
+
+

查理·芒格

+

他长期强调理性、耐心、能力圈和少犯大错。培训里借用他的思路时,重点不在模仿口头风格,而在理解:真正高质量的决策,很多时候来自等待少数足够好的球,而不是什么都想挥棒。

+
来源:Peter D. Kaufman 编,《Poor Charlie's Almanack》(《穷查理宝典》)。
+
+
+

杰西·利弗莫尔

+

市场人物与交易故事经常被拿来讲“顺势”“等待”“人性反复”。但培训时要提醒:关于他的很多流传名句,网上版本混杂,适合引用其思想脉络,不宜把所有热门短句都当成原文。

+
来源:Edwin Lefevre,《Reminiscences of a Stock Operator》(《股票作手回忆录》)。
+
+
+
+
+

关于“现金是期权”这类表达

+

这是一种培训中常用的通俗比喻,意思是强调现金所代表的未来选择权、流动性和主动性,并不是在说现金真的等同于金融学定义中的标准化期权合约。培训使用时,最好把这个边界讲清楚,避免概念混淆。

+
备注:属于认知比喻,不是严格术语替换。
+
+
+

关于本页的现实边界

+

本页讲的是投资认知、风格适配与行为纪律,不涉及个股推荐、收益承诺或短线预测。培训落地时,也建议始终提醒听众:方法再好,也要和风险承受能力、资金来源、投资期限相匹配。

+
备注:用于培训、认知梳理,不构成投资建议。
+
+
+
+ 最后的培训收束 +

如果要把这整页浓缩成一句适合结尾的话,可以这样讲:

+ 炒股不是拼天赋,更像一场修行。 你会不会等,会不会认错,会不会克制,会不会感恩已经到手的利润,这些东西比“哪根均线金叉”更决定你的长期结果。

+ 当你不再把每一次波动都当成机会,不再把每一次回撤都当成耻辱,不再把每一次盈利都想榨到最后一滴,你才算真正从“情绪交易”走向“成熟投资”。

+
+
+
+
+
+ + +
+ + + + diff --git a/public/tools/thought_lab/labs/retro_pinball/index.html b/public/tools/thought_lab/labs/retro_pinball/index.html new file mode 100644 index 0000000..f90af3c --- /dev/null +++ b/public/tools/thought_lab/labs/retro_pinball/index.html @@ -0,0 +1,920 @@ + + + + + + +怀旧弹珠台 · 木制 Bagatelle 物理模拟 + + + +

怀旧弹珠台 · 木制 Bagatelle 物理模拟

+
按住空格 / 按住台面蓄力,松开发射玻璃弹珠 · 冲量法刚体物理 · 全部参数实时可调
+ +
+
+ +
+

本局结束

+

总得分 0

+ +
+
+ +
+
+

回合控制

+
+ + +
+ +

物理参数

+
+
+
+
+
+
+ +

台面布局

+
+
+

修改布局参数会重新开局。

+
关关
+

勾选拨片后两根摆杆如雨刮器刮动;再勾「转动」改为绕轴连续旋转(两杆反向)。随时开关、不重开局。

+ +

物理原理

+ +
+
+ + + + diff --git a/public/tools/thought_lab/labs/three_body_journey/doubao_html_20260807_092844.html b/public/tools/thought_lab/labs/three_body_journey/doubao_html_20260807_092844.html new file mode 100644 index 0000000..61d72a6 --- /dev/null +++ b/public/tools/thought_lab/labs/three_body_journey/doubao_html_20260807_092844.html @@ -0,0 +1,598 @@ + + + + + +思想实验室 · 穿越三体 + + + +
+ +
+
键盘 ← → 切换场景 | 思想实验室 · 穿越三体
+
+
场景解析
+
【真实科学】
+
【小说想象】
+
+
+
+ +
+
+ + +
+
+
+
+ + + + diff --git a/public/tools/thought_lab/labs/three_body_journey/ds_v4_three_body.html b/public/tools/thought_lab/labs/three_body_journey/ds_v4_three_body.html new file mode 100644 index 0000000..9a844d6 --- /dev/null +++ b/public/tools/thought_lab/labs/three_body_journey/ds_v4_three_body.html @@ -0,0 +1,543 @@ + + + + + + +穿越三体 · 第一人称思想实验(DeepSeek V4) + + + + +
+

🌌 穿越三体

+ DeepSeek V4 +
+
+
+
+
+
+

真实科学

    +

    小说想象 / 本场景虚构

      +
      +
      +
      + + +
      +
      ← → 键切换 · 底部文字点击跳过
      + + + + \ No newline at end of file diff --git a/public/tools/thought_lab/labs/three_body_journey/gemini-code-1786066432502.html b/public/tools/thought_lab/labs/three_body_journey/gemini-code-1786066432502.html new file mode 100644 index 0000000..b1646c2 --- /dev/null +++ b/public/tools/thought_lab/labs/three_body_journey/gemini-code-1786066432502.html @@ -0,0 +1,665 @@ + + + + + + 思想实验室 · 穿越三体 + + + + + + +
      +

      思想实验室 · 穿越三体

      +

      一场基于物理与想象的第一人称视觉实验

      + +
      + +
      + +
      +

      真实科学

      + +
      + +
      +

      小说想象

      + +
      + +
      + _ +
      + +
      + + +
      + + + + \ No newline at end of file diff --git a/public/tools/thought_lab/labs/three_body_journey/gpt_5.4_three_body.html b/public/tools/thought_lab/labs/three_body_journey/gpt_5.4_three_body.html new file mode 100644 index 0000000..d5cf3fe --- /dev/null +++ b/public/tools/thought_lab/labs/three_body_journey/gpt_5.4_three_body.html @@ -0,0 +1,976 @@ + + + + + +思想实验室 · 穿越三体 + + + + +
      +
      +
      +
      Thought Lab
      +

      思想实验室 · 穿越三体

      +

      第一人称穿越叙事;真实科学与小说想象分栏标注;三太阳场景为实时牛顿引力积分。

      +
      +
      +
      + +
      + 左右方向键切换场景,空格可重播打字机效果,点击叙事区可直接显示全文。
      + 三太阳场景右下角会显示积分步长、总能量与总动量,方便观察数值稳定性。 +
      + + + +
      + + + +
      +
      + + + + diff --git a/public/tools/thought_lab/labs/three_body_journey/index.html b/public/tools/thought_lab/labs/three_body_journey/index.html new file mode 100644 index 0000000..fad479b --- /dev/null +++ b/public/tools/thought_lab/labs/three_body_journey/index.html @@ -0,0 +1,559 @@ + + + + + + +穿越三体 · 第一人称思想实验 + + + +
      +

      🌌 穿越三体

      + 《三体》同人思想实验 · 真实与想象已分栏标注 +
      +
      + +
      +
      +
      +
      +

      真实科学

        +

        小说想象 / 本场景虚构

          +
          +
          +
          + + +
          + + + + diff --git a/src/server/index.js b/src/server/index.js index c758a40..a7b2015 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -2514,13 +2514,23 @@ const hasValidPrivateClipboardGuid = (req) => { return incoming === value } catch { return false } } +const hasPrivateClipboardBusinessAuth = (req) => { + try { + const mod = require('./private_clipboard') + if (mod && typeof mod.hasBusinessAuth === 'function') return !!mod.hasBusinessAuth(req) + } catch {} + return false +} const hasPrivateClipboardAuth = (req) => { try { const cfg = readPrivateClipboardAuthConfig() if (cfg.enable_auth === false) return true const cookies = parseCookie(req.headers.cookie || '') const name = getPrivateClipboardCookieName() - return cookies[name] === '1' + if (cookies[name] === '1') return true + // Rule 12.10:第4层业务登录态(HMAC 签名校验通过)同样视为外层有效授权, + // 避免 gate cookie 过期后已登录用户“贴和传没有响应” + return hasPrivateClipboardBusinessAuth(req) } catch { return false } } const isPrivateClipboardForceGuid = () => { diff --git a/src/server/private_clipboard.js b/src/server/private_clipboard.js index 6a6a34f..ba544d1 100644 --- a/src/server/private_clipboard.js +++ b/src/server/private_clipboard.js @@ -696,4 +696,4 @@ const createRouter = () => { return router } -module.exports = { createRouter } +module.exports = { createRouter, hasBusinessAuth: hasAuth } diff --git a/src/server/thought_lab/labs/four_dim_space/config.json b/src/server/thought_lab/labs/four_dim_space/config.json new file mode 100644 index 0000000..b436a7e --- /dev/null +++ b/src/server/thought_lab/labs/four_dim_space/config.json @@ -0,0 +1,15 @@ +{ + "_comment": "思想实验室 · 四维空间实验室:第四空间维的数学可视化(科学教研用)。正多胞体(5/8/16/24/600-胞体)严格坐标 + 六旋转平面 + 等倾双旋转的 4D→3D→2D 透视投影;四维超球被三维空间扫过的切片动画(r=√(R²−w²));维度类比表。所有顶点/边数由代码实算,页面明确标注科学边界:观测空间为 3 维,额外空间维仅见于弦论/卡鲁扎-克莱因且紧致于普朗克尺度,本 lab 是数学可视化工具。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。", + "id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致", + "id": "four_dim_space", + "name_comment": "首页卡片显示名称", + "name": "四维空间实验室", + "icon_comment": "首页卡片图标(emoji)", + "icon": "💠", + "desc_comment": "一句话简介,作为卡片 tooltip 展示", + "desc": "正多胞体旋转投影 · 超球切片 · 维度类比", + "sort_order_comment": "卡片排序,数值越小越靠前", + "sort_order": 70, + "enabled_comment": "是否启用(false 时列表接口不返回该 lab)", + "enabled": true +} diff --git a/src/server/thought_lab/labs/humanity_and_investing/config.json b/src/server/thought_lab/labs/humanity_and_investing/config.json new file mode 100644 index 0000000..73d36c8 --- /dev/null +++ b/src/server/thought_lab/labs/humanity_and_investing/config.json @@ -0,0 +1,15 @@ +{ + "_comment": "思想实验室 · 人性与投资:长卷式培训网页。用通俗方式讲现金与机会、六种投资风格、人性弱点、纪律节奏和培训场景。强调金融常识,不做荐股,不做收益承诺;涉及名人时附书籍或公开来源线索。本 lab 完全独立;未来若需要后台逻辑,统一放在本文件夹内。", + "id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致", + "id": "humanity_and_investing", + "name_comment": "首页卡片显示名称", + "name": "人性与投资", + "icon_comment": "首页卡片图标(emoji)", + "icon": "📈", + "desc_comment": "一句话简介,作为卡片 tooltip 展示", + "desc": "长卷培训页 · 投资风格 · 人性镜子 · 纪律节奏", + "sort_order_comment": "卡片排序,数值越小越靠前", + "sort_order": 90, + "enabled_comment": "是否启用(false 时列表接口不返回该 lab)", + "enabled": true +} diff --git a/src/server/thought_lab/labs/retro_pinball/config.json b/src/server/thought_lab/labs/retro_pinball/config.json new file mode 100644 index 0000000..33b75db --- /dev/null +++ b/src/server/thought_lab/labs/retro_pinball/config.json @@ -0,0 +1,15 @@ +{ + "_comment": "思想实验室 · 怀旧弹珠台:HTML + Canvas 2D 模拟老式桌面弹珠台。竖向台面,右下弹簧拉杆发射玻璃弹珠,盘面密布圆形钉子,左下 6 个落球坑计分,支持重力、台面偏坡、碰撞弹性、空气阻尼、碰撞摩擦、发射弹力、钉子尺寸与密度微调。本 lab 完全独立;未来若需要后台逻辑,统一放在本文件夹内。", + "id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致", + "id": "retro_pinball", + "name_comment": "首页卡片显示名称", + "name": "怀旧弹珠台", + "icon_comment": "首页卡片图标(emoji)", + "icon": "🎯", + "desc_comment": "一句话简介,作为卡片 tooltip 展示", + "desc": "桌面弹珠台 · 可调参数 · 玻璃弹珠碰撞模拟", + "sort_order_comment": "卡片排序,数值越小越靠前", + "sort_order": 96, + "enabled_comment": "是否启用(false 时列表接口不返回该 lab)", + "enabled": true +} diff --git a/src/server/thought_lab/labs/three_body_journey/config.json b/src/server/thought_lab/labs/three_body_journey/config.json new file mode 100644 index 0000000..ca04b22 --- /dev/null +++ b/src/server/thought_lab/labs/three_body_journey/config.json @@ -0,0 +1,15 @@ +{ + "_comment": "思想实验室 · 穿越三体:基于刘慈欣《三体》三部曲的第一人称穿越思想实验。从地球出发,依次经历星际航行、三体星系三太阳(真实三体问题实时数值积分,展示混沌)、水滴、二向箔二维化、小宇宙,最终回到地球。每个场景附『真实科学 / 小说想象』双栏文字标注,过渡场景为 imagination 并明确文字说明。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。", + "id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致", + "id": "three_body_journey", + "name_comment": "首页卡片显示名称", + "name": "穿越三体", + "icon_comment": "首页卡片图标(emoji)", + "icon": "🌌", + "desc_comment": "一句话简介,作为卡片 tooltip 展示", + "desc": "第一人称穿越 · 三体场景 · 真实/想象标注", + "sort_order_comment": "卡片排序,数值越小越靠前", + "sort_order": 80, + "enabled_comment": "是否启用(false 时列表接口不返回该 lab)", + "enabled": true +}