数字我 Digital Me:全量入库(大文件走 Git LFS)
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// 采【加权求和版】引擎的产出 —— 这一刀的「旧版」参照。
|
||||
//
|
||||
// ★★ 这个脚本【只在改 dynasties.js 之前跑过一次】,跑完它采下来的 dynasties-weighted-sum.json
|
||||
// 就是死的。★ 绝不要在改完引擎之后再跑它来「更新」参照 —— 那等于把被告改成原告。
|
||||
//
|
||||
// ⚠ 它自证真伪:采下来的五个单轨朝代,指纹必须与 dynasties-pre-multitrack.json 里
|
||||
// 【旧代码采的那份】逐字相同。对得上 ⇒ 这份快照确实是旧引擎的产出,不是我编的。
|
||||
// (本项目的铁律:最难发现的错是「假订正」。一份自己造的「旧版」参照,就是假订正的温床。)
|
||||
//
|
||||
// 跑:node scripts/capture-weighted-sum.mjs
|
||||
//
|
||||
// ⚠ 它住在 scripts/ 而不是 test/,是【故意的】:`node --test` 会把 test/ 底下的【每一个】文件
|
||||
// 都当测试跑(不只是 *.test.js)。而本脚本的自证在引擎改完之后【必然抛异常】——
|
||||
// 那是它设计上的正确行为,却会把 npm test 弄红。放在 scripts/ 下,它才是它该是的东西:
|
||||
// 一次性的采集工具,不是测试。
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { DYNASTIES, computeRank, profileFromSubs } from '../src/dynasties.js';
|
||||
import { SUB_REGISTRY } from '../src/talents.js';
|
||||
|
||||
// ⚠ 必须与 dynasties-tracks.test.js 的 matrix() 【完全同构】,否则指纹对不上。
|
||||
function matrix() {
|
||||
const ALL = SUB_REGISTRY.map((s) => s.id);
|
||||
const combos = [[]];
|
||||
for (const s of ALL) combos.push([s]);
|
||||
for (let i = 0; i < ALL.length; i++) for (let j = i + 1; j < ALL.length; j++) combos.push([ALL[i], ALL[j]]);
|
||||
combos.push(['rigorous', 'datadriven', 'concise', 'shortfirst', 'patient', 'calm']);
|
||||
combos.push(['poetic', 'story', 'vivid']);
|
||||
combos.push(['socratic', 'humor', 'poetic', 'story', 'vivid']);
|
||||
combos.push(['blunt', 'motivate']);
|
||||
combos.push(ALL);
|
||||
const out = new Map();
|
||||
for (const subs of combos) {
|
||||
for (const wang of [0, 30, 55, 60, 75, 80, 90, 100]) {
|
||||
out.set(`${subs.join('+') || '∅'}|w${wang}`, profileFromSubs(subs, { wang }));
|
||||
}
|
||||
}
|
||||
return [...out.entries()];
|
||||
}
|
||||
|
||||
// ── 自证:五个单轨朝代的指纹必须与【旧代码采的】 pre-multitrack fixture 对得上 ──
|
||||
const PRE = JSON.parse(fs.readFileSync(new URL('../test/fixtures/dynasties-pre-multitrack.json', import.meta.url), 'utf8'));
|
||||
const rows = matrix();
|
||||
if (rows.length !== PRE._matrix.count) throw new Error(`矩阵变了:${rows.length} ≠ ${PRE._matrix.count}`);
|
||||
const sorted = rows.slice().sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||
for (const d of ['han', 'weijin', 'nanchao', 'tang', 'ming']) {
|
||||
const h = crypto.createHash('sha256');
|
||||
for (const [k, p] of sorted) h.update(k + '|' + JSON.stringify(computeRank(p, d)));
|
||||
const got = h.digest('hex');
|
||||
if (got !== PRE.hashes[d]) {
|
||||
throw new Error(
|
||||
`✗ ${d} 的指纹与 pre-multitrack 对不上 —— 当前代码【已经不是】加权求和版了。\n` +
|
||||
' 这个脚本只能在改引擎【之前】跑。现在跑它,采到的是新引擎的产出,' +
|
||||
'整个「新旧对照」就成了自己跟自己比。'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 采:七朝 × 1128 profile 的【判定投影】──
|
||||
// ⚠ 只采判定相关的格子,不采 reasons/cites(那是文案,几十 MB,且不参与判定)。
|
||||
const proj = (r) => (r === null ? null : {
|
||||
ch: r.channel ? r.channel.id : null,
|
||||
tier: r.rank.tier,
|
||||
title: r.rank.title,
|
||||
grade: r.rank.grade,
|
||||
unit: r.rank.unit,
|
||||
ceil: r.ceiling ? r.ceiling.tier : null,
|
||||
cat: r.category || '',
|
||||
stig: !!r.stigma,
|
||||
blk: r.blocked.length,
|
||||
});
|
||||
|
||||
const out = {
|
||||
_what: '【加权求和版】引擎(score += val(k)*weight → tier = round(score/100*5))的产出,七朝 × 1128 profile。',
|
||||
_why:
|
||||
'★ 这一刀【必须】让输出变(原文里没有加权求和的原型)。所以这个文件【不是迁移锁】—— ' +
|
||||
'它是【被告】:新引擎每改动一格,都要在 dynasties-shape.test.js 里说清楚是哪条原文要求它这么改。',
|
||||
_dontRegen:
|
||||
'★★ 绝不要用新引擎重新生成本文件。它的全部价值在于它是【旧代码】的产出。' +
|
||||
'重生成 = 把被告改成原告 = 本项目铁律里的「假订正」。' +
|
||||
'自证:五个单轨朝代的指纹与 dynasties-pre-multitrack.json 逐字相同(采集时已校验并通过)。',
|
||||
_capturedBy: 'scripts/capture-weighted-sum.mjs(2026-07-16,改引擎之前)',
|
||||
_matrix: PRE._matrix,
|
||||
_selfCheck: '✓ han/weijin/nanchao/tang/ming 五朝指纹 = dynasties-pre-multitrack.json 的指纹(采集时校验通过)',
|
||||
rows: Object.fromEntries(
|
||||
sorted.map(([k, p]) => [k, Object.fromEntries(DYNASTIES.map((d) => [d.id, proj(computeRank(p, d.id))]))])
|
||||
),
|
||||
};
|
||||
fs.writeFileSync(new URL('../test/fixtures/dynasties-weighted-sum.json', import.meta.url), JSON.stringify(out));
|
||||
console.log(`✓ 采到 ${sorted.length} 个 profile × ${DYNASTIES.length} 朝;五朝指纹自证通过。`);
|
||||
@@ -0,0 +1,71 @@
|
||||
// 用 CDP 给页面注入 dm_token 后截图(验证需登录的后台)。
|
||||
// 用法:node scripts/cdp-shot.mjs <token> <targetPath> <outPng>
|
||||
// 前置:chrome 已带 --remote-debugging-port=9222 启动,并已打开 http://localhost:5173/login
|
||||
import WebSocket from 'ws';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
const [, , TOKEN, TARGET, OUT] = process.argv;
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function listTargets() {
|
||||
const res = await fetch('http://localhost:9222/json/list');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 找到 5173 的页面 target。
|
||||
let ws = '';
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const ts = await listTargets();
|
||||
const t = ts.find((x) => x.type === 'page' && /localhost:5173/.test(x.url));
|
||||
if (t) { ws = t.webSocketDebuggerUrl; break; }
|
||||
await wait(300);
|
||||
}
|
||||
if (!ws) throw new Error('no 5173 page target');
|
||||
|
||||
const sock = new WebSocket(ws, { perMessageDeflate: false, maxPayload: 64 * 1024 * 1024 });
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((resolve) => { const mid = ++id; pending.set(mid, resolve); sock.send(JSON.stringify({ id: mid, method, params })); });
|
||||
|
||||
await new Promise((r) => sock.on('open', r));
|
||||
sock.on('message', (buf) => {
|
||||
const msg = JSON.parse(buf.toString());
|
||||
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg.result); pending.delete(msg.id); return; }
|
||||
if (msg.method === 'Network.responseReceived') {
|
||||
const u = msg.params?.response?.url || '';
|
||||
if (/\/api\//.test(u)) console.log(' NET', msg.params.response.status, u.replace('http://localhost:3701', ''));
|
||||
}
|
||||
if (msg.method === 'Runtime.exceptionThrown') console.log(' EXC', msg.params?.exceptionDetails?.text, msg.params?.exceptionDetails?.exception?.description?.slice(0, 120));
|
||||
});
|
||||
|
||||
await send('Page.enable');
|
||||
await send('Runtime.enable');
|
||||
await send('Network.enable');
|
||||
// 先到根页把 token 写进 localStorage。
|
||||
await send('Page.navigate', { url: 'http://localhost:5173/' });
|
||||
await wait(2500);
|
||||
const set = await send('Runtime.evaluate', {
|
||||
expression: `localStorage.setItem('dm_token', ${JSON.stringify(TOKEN)}); localStorage.getItem('dm_token')?.length || 0`,
|
||||
returnByValue: true,
|
||||
});
|
||||
console.log('token len in storage:', set?.result?.value);
|
||||
// 第一次整页跳(预热会话,冷启动可能被守卫弹走)。
|
||||
await send('Runtime.evaluate', { expression: `location.href='http://localhost:5173${TARGET}'` });
|
||||
await wait(5000);
|
||||
// 第二次跳目标(此时 store 已 authed、user 已拉取 → 稳定停在目标页)。
|
||||
await send('Page.navigate', { url: `http://localhost:5173${TARGET}` });
|
||||
await wait(6000);
|
||||
const diag = await send('Runtime.evaluate', {
|
||||
expression: `JSON.stringify({path: location.pathname, tok: (localStorage.getItem('dm_token')||'').length, h1: document.querySelector('h1')?.textContent?.slice(0,20), bodyHas: document.body.innerText.includes('大师墙')})`,
|
||||
returnByValue: true,
|
||||
});
|
||||
console.log('after nav:', diag?.result?.value);
|
||||
const { data } = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
|
||||
writeFileSync(OUT, Buffer.from(data, 'base64'));
|
||||
console.log('saved', OUT);
|
||||
sock.close();
|
||||
process.exit(0);
|
||||
}
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,183 @@
|
||||
// ★★★★ 2026-07-17 第三刀:【全表死物 census —— 「读 0 次 / 改变 0 格」逐个列出】
|
||||
// 老板逐字:「把每个进 reasons 的数、每个 key 位、每个 axis,逐个 census:被读几次?改变几格?
|
||||
// 读 0 次 或 改变 0 格 的,全部列出来。」
|
||||
//
|
||||
// ⚠ 口径三条,写死在这里:
|
||||
// ① 【改变几格】= 【突变数据点 + 比对引擎自己的产物】,不是复算。
|
||||
// ② ★【「改变 0 格」比「读 0 次」更宽,而它才是产品要问的那一问】——
|
||||
// 南朝的 lex 被【读了 98,304 次、裁决了 81,920 次】,而它【改变 0 格】(第六刀实测)。
|
||||
// ⇒ 一个被读了八万次的东西,可以什么都没买到。故本 census 量的是【买到了什么】。
|
||||
// ③ ★★★【每一个读数都必须报它是在哪个 chushen 配置下量的】——
|
||||
// 第六刀的验收判词逐字:「一个不说配置的读数,不是读数。」
|
||||
// ⚠ 而这一条【本 census 第一版自己就栽了】:默认 chushen 未答 ⇒ 辽三条路全关
|
||||
// ⇒ 辽的每一个数据点都报「死」。那不是死,那是【我没让它活】。
|
||||
// ⇒ 故:辽跑 3 种(未答/契丹/汉),明跑 2 种(未答/倡優之家),其余五朝无出身维。
|
||||
// ★【死】= 在【它自己那一朝的每一种配置下】都改变 0 格。
|
||||
import { DYNASTIES, computeRank, computeAxes, AXIS_IDS } from '../src/dynasties.js';
|
||||
import { chushenOf } from '../src/chushen.js';
|
||||
import { SUB_REGISTRY } from '../src/talents.js';
|
||||
|
||||
const SUBS = SUB_REGISTRY.map((s) => s.id);
|
||||
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100]; // 与 census-kai.mjs 逐字同 —— 门槛 30/55/75 的两侧
|
||||
// ★ 2^16 subs 去重成 2,790 个不同的六轴取值(带权 w)——【判决只看六轴,不看 subs】。
|
||||
// ⇒ 与 524,288 逐格跑【逐字等价】,而快 20 倍。w 把「条次」还原回「profile 数」。
|
||||
const TUPLES = (() => {
|
||||
const m = new Map();
|
||||
for (let b = 0; b < (1 << 16); b++) {
|
||||
const subs = []; for (let i = 0; i < 16; i++) if (b & (1 << i)) subs.push(SUBS[i]);
|
||||
const a = computeAxes(subs);
|
||||
const k = AXIS_IDS.map((x) => a[x]).join(',');
|
||||
m.set(k, (m.get(k) || 0) + 1);
|
||||
}
|
||||
return [...m].map(([k, w]) => ({ axes: Object.fromEntries(k.split(',').map((v, i) => [AXIS_IDS[i], Number(v)])), w }));
|
||||
})();
|
||||
const PER_DYN = TUPLES.reduce((s, t) => s + t.w, 0) * WANGS.length; // = 524,288
|
||||
|
||||
// 每朝要跑的 chushen 配置。★ null 也是一种配置(「这一问还没问出口」),不是缺省值。
|
||||
const CONFIGS = {};
|
||||
for (const d of DYNASTIES) {
|
||||
const dim = chushenOf(d.id);
|
||||
CONFIGS[d.id] = dim ? [null, ...dim.values.map((v) => v.id)] : [null];
|
||||
}
|
||||
|
||||
// ★ 存 hash 不存串:156,240 格 × 3.2KB 会把内存吃穿(第一版就是这么死的)。
|
||||
const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; };
|
||||
// 【整页】用户读得到的全部(reasons 的每一个字都在里面)
|
||||
const page = (r) => fnv(JSON.stringify(r));
|
||||
// 【判词】这一部传写的那一句:你走哪条路、第几阶、什么头衔
|
||||
const verdict = (r) => fnv(`${r.channel ? r.channel.id : '-'}|${r.rank.tier}|${r.rank.title}|${r.nearest || '-'}`);
|
||||
|
||||
function scan(dynId, chushen, over = null) {
|
||||
const out = [];
|
||||
for (const t of TUPLES) for (const wang of WANGS) {
|
||||
const axes = over ? { ...t.axes, ...over.axes } : t.axes;
|
||||
const r = computeRank({ axes, wang, chushen, ...(over ? over.extra : null) }, dynId);
|
||||
out.push({ w: t.w, p: page(r), v: verdict(r) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const BASE = {};
|
||||
for (const d of DYNASTIES) for (const cs of CONFIGS[d.id]) BASE[`${d.id}|${cs}`] = scan(d.id, cs);
|
||||
|
||||
const diff = (dynId, cs, now) => {
|
||||
const base = BASE[`${dynId}|${cs}`];
|
||||
let dv = 0, dp = 0;
|
||||
for (let i = 0; i < base.length; i++) {
|
||||
if (base[i].v !== now[i].v) dv += base[i].w;
|
||||
if (base[i].p !== now[i].p) dp += base[i].w;
|
||||
}
|
||||
return { dv, dp };
|
||||
};
|
||||
// 突变一个数据点 → 在【那一朝的每一种 chushen 配置】下重扫 → 逐配置报
|
||||
function mutate(dynId, apply, undo) {
|
||||
apply();
|
||||
const per = CONFIGS[dynId].map((cs) => ({ cs, ...diff(dynId, cs, scan(dynId, cs)) }));
|
||||
undo();
|
||||
return { per, dv: Math.max(...per.map((p) => p.dv)), dp: Math.max(...per.map((p) => p.dp)) };
|
||||
}
|
||||
function perturb(dynId, over) {
|
||||
const per = CONFIGS[dynId].map((cs) => ({ cs, ...diff(dynId, cs, scan(dynId, cs, over)) }));
|
||||
return { per, dv: Math.max(...per.map((p) => p.dv)), dp: Math.max(...per.map((p) => p.dp)) };
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
const push = (kind, dyn, what, r, note = '') => rows.push({ kind, dyn, what, ...r, note });
|
||||
|
||||
for (const d of DYNASTIES) {
|
||||
for (const c of d.channels) {
|
||||
const x = c.xuan;
|
||||
// ── ① 每一个 gate 闸口:删掉它
|
||||
for (const k of Object.keys(c.gate || {})) {
|
||||
const old = c.gate[k];
|
||||
push('gate', d.id, `${c.id} gate{${k}:${old}} 删掉`,
|
||||
mutate(d.id, () => { delete c.gate[k]; }, () => { c.gate[k] = old; }));
|
||||
// ★★ 闸的【那个数】本身买到了什么?六轴取值只有 40/63/73/79/83(sat 曲线的产物)
|
||||
// ⇒ (40,63] 里的任何 need 是【同一道闸】。50 与 55 是不是同一道闸 —— 让引擎自己说。
|
||||
if (k !== 'wang' && k !== 'lao') {
|
||||
for (const alt of [41, 50, 55, 63]) {
|
||||
if (alt === old) continue;
|
||||
push('gate-num', d.id, `${c.id} gate{${k}:${old}→${alt}}`,
|
||||
mutate(d.id, () => { c.gate[k] = alt; }, () => { c.gate[k] = old; }));
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── ② 每一个 key 位:删掉它
|
||||
const key = x.key || [];
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
push('key-pos', d.id, `${c.id} key[${i}]:${key[i]} 删掉`,
|
||||
mutate(d.id, () => { x.key = key.filter((_, j) => j !== i); }, () => { x.key = key; }));
|
||||
}
|
||||
if (key.length) push('key-all', d.id, `${c.id} key=${JSON.stringify(key)} 整条删掉`,
|
||||
mutate(d.id, () => { x.key = []; }, () => { x.key = key; }));
|
||||
// ── ③ 每一条 kemu
|
||||
const kemu = x.kemu || [];
|
||||
for (let i = 0; i < kemu.length; i++) {
|
||||
push('kemu', d.id, `${c.id} kemu[${i}]:${kemu[i].axis}>=${kemu[i].need} 删掉`,
|
||||
mutate(d.id, () => { x.kemu = kemu.filter((_, j) => j !== i); }, () => { x.kemu = kemu; }));
|
||||
}
|
||||
// ── ④ seat ±1
|
||||
const seat = x.seat;
|
||||
push('seat', d.id, `${c.id} seat ${seat}→${seat + 1}`,
|
||||
mutate(d.id, () => { x.seat = seat + 1; }, () => { x.seat = seat; }));
|
||||
// ── ⑤ ceiling:两问分开 —— 它在【择路】那一级读,和它在 tierOf 里【夹】,是两件事
|
||||
if (c.ceiling) {
|
||||
const ct = c.ceiling.tier;
|
||||
if (ct !== 5) push('ceiling', d.id, `${c.id} ceiling.tier ${ct}→5`,
|
||||
mutate(d.id, () => { c.ceiling.tier = 5; }, () => { c.ceiling.tier = ct; }));
|
||||
// ★★ 【clamp 那一夹】—— 纯算术,不用跑:tier = clamp(seat + 通过条数, 0, ceiling.tier)
|
||||
// ⇒ seat + kemu.length 是 tier 的【最大可能值】。它 ≤ ceiling.tier ⇒ 那个 clamp 【永远不夹】。
|
||||
const most = (x.seat || 0) + (x.kemu || []).length;
|
||||
push('ceiling-clamp', d.id, `${c.id} tierOf 的 clamp(…,0,${ct}):seat+全通=${most}`,
|
||||
most > ct ? { per: [], dv: -1, dp: -1 } : { per: [], dv: 0, dp: 0 },
|
||||
most > ct ? '夹得住' : '★ 恒不夹(seat+kemu ≤ ceiling)');
|
||||
}
|
||||
// ── ⑥ 标记位
|
||||
for (const f of ['hollow', 'stigma', 'regular']) {
|
||||
const old = c[f];
|
||||
push('flag', d.id, `${c.id} ${f} ${!!old}→${!old}`,
|
||||
mutate(d.id, () => { c[f] = !old; }, () => { c[f] = old; }));
|
||||
}
|
||||
const as = c.assimilation;
|
||||
push('flag', d.id, `${c.id} assimilation ${as || 0}→${as ? 0 : 0.5}`,
|
||||
mutate(d.id, () => { c.assimilation = as ? 0 : 0.5; }, () => { c.assimilation = as; }));
|
||||
}
|
||||
}
|
||||
// ── ⑦ 每一条轴:整个钉死成 40(=「这条轴上一项都没点过」)
|
||||
for (const d of DYNASTIES) for (const a of AXIS_IDS) {
|
||||
push('axis', d.id, `轴 ${a} 全钉成 40(=一项都没点)`, perturb(d.id, { axes: { [a]: 40 } }));
|
||||
}
|
||||
// ── ⑧ profile 上那几个【引擎的签名收了的】字段:喂值进去
|
||||
for (const d of DYNASTIES) for (const [nm, extra] of [
|
||||
['lao', { lao: 9999 }], ['soul', { soul: 100 }],
|
||||
['counts', { counts: { catchphrase: 9, attitude: 9, timeline: 9 } }], ['profession', { profession: 'poet' }],
|
||||
]) push('profile', d.id, `profile.${nm} = ${JSON.stringify(Object.values(extra)[0])}`, perturb(d.id, { extra }));
|
||||
|
||||
const N = (n) => (n < 0 ? '—' : n.toLocaleString());
|
||||
const pad = (s, n) => { s = String(s); let w = 0; for (const ch of s) w += ch.charCodeAt(0) > 0x2000 ? 2 : 1; return s + ' '.repeat(Math.max(1, n - w)); };
|
||||
const num = (s, n) => String(s).padStart(n);
|
||||
const isDead = (r) => r.dv === 0 && r.dp === 0;
|
||||
const onlyPage = (r) => r.dv === 0 && r.dp > 0;
|
||||
|
||||
console.log(`\n══ 死物 census ══ 每朝每配置 ${N(PER_DYN)} 个 profile(2,790 个六轴取值 × 8 wang,带权 = 524,288)`);
|
||||
console.log(`══ 配置:辽 3 种(未答/契丹/汉)、明 2 种(未答/倡優之家)、其余五朝无出身维`);
|
||||
console.log(`══ 【改变 0 格】= 这个数据点在整张表上什么都没买到(取【全部配置里最大的那个】,故不冤枉它)\n`);
|
||||
console.log(pad('类', 14) + pad('朝', 9) + pad('数据点', 44) + num('判词变', 10) + num('整页变', 10) + ' 备注');
|
||||
console.log('─'.repeat(100));
|
||||
const want = process.argv.includes('--all') ? () => true : (r) => isDead(r) || onlyPage(r);
|
||||
for (const r of rows) {
|
||||
if (!want(r)) continue;
|
||||
console.log(pad(r.kind, 14) + pad(r.dyn, 9) + pad(r.what, 44) + num(N(r.dv), 10) + num(N(r.dp), 10) +
|
||||
' ' + (isDead(r) ? '★★ 死(0 格)' : onlyPage(r) ? '★ 只说话不判事' : '') + (r.note ? ' ' + r.note : ''));
|
||||
}
|
||||
console.log(`\n──────────────────────────────────────────────`);
|
||||
console.log(`数据点总数 ${rows.length}`);
|
||||
console.log(`★★【死】改变 0 格 ${rows.filter(isDead).length}`);
|
||||
console.log(`★【只改整页不改判词】 ${rows.filter(onlyPage).length} =「它说了一句话,而它什么都没判」`);
|
||||
console.log(`活的(改判词) ${rows.filter((r) => r.dv > 0).length}`);
|
||||
console.log(`\n★ 逐配置明细(死物在【每一种】配置下都是 0):--verbose`);
|
||||
if (process.argv.includes('--verbose')) {
|
||||
for (const r of rows) {
|
||||
if (!isDead(r) || !r.per.length || r.per.length < 2) continue;
|
||||
console.log(pad(r.kind + ' ' + r.dyn + ' ' + r.what, 56) + r.per.map((p) => `${p.cs || '未答'}:${p.dv}/${p.dp}`).join(' '));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// ★★★★★ 2026-07-17 第三刀:【gapOf —— 「排在后面 ≠ 不参选」这一族的第八次】
|
||||
//
|
||||
// 【机理,先说死,它是算术,不是史学】
|
||||
// 六轴的取值是 sat() 饱和曲线的产物,【只有五个值】:40 / 63 / 73 / 79 / 83(本刀实测)。
|
||||
// ⇒ (40,63] 之间【没有任何一个 profile 落得进去】。
|
||||
// ⇒ 【gate{wu:41} 与 gate{wu:55} 与 gate{wu:63} 是【同一道闸】】—— 开/关逐格相同,零例外。
|
||||
// ★ 即:55 这个数【作为闸是一个空操作】—— 本文件自己也这么说:
|
||||
// 「因此 gate 的口径是:55 ≈「这条轴上至少点过一项」,70 ≈「至少两项」。」
|
||||
// ⇒ ★★【它自陈是一个【代号】,不是一个刻度。】
|
||||
//
|
||||
// 【而它在另一个地方【被当成刻度读了】】
|
||||
// `gapOf(t) = Math.max(...t.missing.map((m) => m.need - m.got))`
|
||||
// ⇒ 【need − got】。★ need 是一个代号,got 是一个曲线的产物 —— 两者相减【不是一段距离】。
|
||||
// ⇒ 而 nearest 的第二级就是拿它排的。⇒ 【一个自陈是代号的数,在暗处裁决了「你离哪条路最近」。】
|
||||
//
|
||||
// 本 census 分三问,逐问都拿引擎自己的产物答:
|
||||
// ① 【同一道闸的那几个数,开/关变几格?】(应为 0 —— 若不为 0,我上面的机理是错的)
|
||||
// ② 【而它们让 nearest 变几格?】
|
||||
// ③ 【gapOf 裁决的时候,比的是不是同一条轴?】(同 lexCompare 的那一问,实测同轴 0 次)
|
||||
import { DYNASTIES, computeRank, computeAxes, AXIS_IDS } from '../src/dynasties.js';
|
||||
import { chushenOf } from '../src/chushen.js';
|
||||
import { SUB_REGISTRY } from '../src/talents.js';
|
||||
|
||||
const SUBS = SUB_REGISTRY.map((s) => s.id);
|
||||
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100];
|
||||
const TUPLES = (() => {
|
||||
const m = new Map();
|
||||
for (let b = 0; b < (1 << 16); b++) {
|
||||
const subs = []; for (let i = 0; i < 16; i++) if (b & (1 << i)) subs.push(SUBS[i]);
|
||||
const a = computeAxes(subs);
|
||||
m.set(AXIS_IDS.map((x) => a[x]).join(','), (m.get(AXIS_IDS.map((x) => a[x]).join(',')) || 0) + 1);
|
||||
}
|
||||
return [...m].map(([k, w]) => ({ axes: Object.fromEntries(k.split(',').map((v, i) => [AXIS_IDS[i], Number(v)])), w }));
|
||||
})();
|
||||
const CONFIGS = {};
|
||||
for (const d of DYNASTIES) { const dim = chushenOf(d.id); CONFIGS[d.id] = dim ? [null, ...dim.values.map((v) => v.id)] : [null]; }
|
||||
const N = (n) => n.toLocaleString();
|
||||
const pad = (s, n) => { s = String(s); let w = 0; for (const ch of s) w += ch.charCodeAt(0) > 0x2000 ? 2 : 1; return s + ' '.repeat(Math.max(1, n - w)); };
|
||||
|
||||
// ★ 六轴的全部可能取值 —— 让引擎自己报,不许我手写
|
||||
const VALS = {}; for (const a of AXIS_IDS) VALS[a] = [...new Set(TUPLES.map((t) => t.axes[a]))].sort((x, y) => x - y);
|
||||
console.log('══ ① 六轴的取值:sat() 曲线只给得出这几个数 ══');
|
||||
for (const a of AXIS_IDS) console.log(` ${pad(a, 6)} ${VALS[a].join(' ')}`);
|
||||
|
||||
// 一道闸 need=d 的【等价类】:所有让「开/关」逐格相同的整数 n
|
||||
const classOf = (axis, d) => {
|
||||
const vs = VALS[axis];
|
||||
const passes = (n) => vs.filter((v) => v >= n).join(',');
|
||||
const me = passes(d);
|
||||
const out = [];
|
||||
for (let n = 1; n <= 100; n++) if (passes(n) === me) out.push(n);
|
||||
return out;
|
||||
};
|
||||
|
||||
console.log('\n══ ② 每一道六轴闸的【等价类】—— 类里的任何一个数,是【同一道闸】 ══');
|
||||
console.log(pad('闸', 34) + pad('等价类(这些数【是同一道闸】)', 34) + '类的大小');
|
||||
console.log('─'.repeat(84));
|
||||
const GATES = [];
|
||||
for (const d of DYNASTIES) for (const c of d.channels) for (const [k, need] of Object.entries(c.gate || {})) {
|
||||
if (k === 'wang' || k === 'lao') continue; // wang 是 0–100 的自由值,不是 sat 的产物 ⇒ 它的数是真的
|
||||
const cls = classOf(k, need);
|
||||
GATES.push({ d, c, k, need, cls });
|
||||
console.log(pad(`${d.id}/${c.id} gate{${k}:${need}}`, 34) + pad(`${cls[0]}–${cls[cls.length - 1]}`, 34) + cls.length);
|
||||
}
|
||||
|
||||
// ── 跑:把每一道闸换成它等价类里的每一个端点,看【开/关】与【nearest】各变几格
|
||||
console.log('\n══ ③ 换成等价类里的另一个数:【开/关】变几格?【nearest】变几格? ══');
|
||||
console.log(pad('闸 → 换成', 40) + '开/关变'.padStart(10) + 'nearest 变'.padStart(12) + ' ★');
|
||||
console.log('─'.repeat(72));
|
||||
const scan = (dynId, cs) => {
|
||||
const out = [];
|
||||
for (const t of TUPLES) for (const wang of WANGS) {
|
||||
const r = computeRank({ axes: t.axes, wang, chushen: cs }, dynId);
|
||||
// 【开/关】= 哪几条路对你开着(引擎自己的 kai + blocked)
|
||||
const gates = r.kai.map((k) => k.channelId).sort().join(',') + '#' + r.blocked.map((b) => b.channel).sort().join(',');
|
||||
out.push({ w: t.w, gates, near: r.nearest || '-' });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const BASE = {};
|
||||
for (const d of DYNASTIES) for (const cs of CONFIGS[d.id]) BASE[`${d.id}|${cs}`] = scan(d.id, cs);
|
||||
let deadGate = 0, movedNear = 0;
|
||||
for (const g of GATES) {
|
||||
for (const alt of [g.cls[0], g.cls[g.cls.length - 1]]) {
|
||||
if (alt === g.need) continue;
|
||||
const old = g.c.gate[g.k];
|
||||
g.c.gate[g.k] = alt;
|
||||
let dg = 0, dn = 0;
|
||||
for (const cs of CONFIGS[g.d.id]) {
|
||||
const b = BASE[`${g.d.id}|${cs}`], now = scan(g.d.id, cs);
|
||||
for (let i = 0; i < b.length; i++) { if (b[i].gates !== now[i].gates) dg += b[i].w; if (b[i].near !== now[i].near) dn += b[i].w; }
|
||||
}
|
||||
g.c.gate[g.k] = old;
|
||||
if (dg === 0) deadGate++;
|
||||
if (dn > 0) movedNear += dn;
|
||||
console.log(pad(`${g.d.id}/${g.c.id} {${g.k}:${g.need}→${alt}}`, 40) + N(dg).padStart(10) + N(dn).padStart(12) +
|
||||
' ' + (dg === 0 ? '★ 同一道闸(0 格)' : '⚠ 不是同一道闸!') + (dn > 0 ? ' ★★ 而 nearest 变了' : ''));
|
||||
}
|
||||
}
|
||||
console.log(`\n★★★【${deadGate} 处:换个数,开/关一格都不动 —— 它作为闸是空操作】`);
|
||||
console.log(`★★★【而 nearest 一共变了 ${N(movedNear)} 格 —— 那个空操作在暗处裁决了「你离哪条路最近」】`);
|
||||
|
||||
// ── ④ gapOf 裁决的时候,比的是不是同一条轴?
|
||||
console.log('\n══ ④ 【无路者】那一支:nearest 是谁判的?gapOf 比的是不是同一条轴? ══');
|
||||
const gapOf = (ms) => (ms.length ? Math.max(...ms.map((m) => m.need - m.got)) : 0);
|
||||
const worstAxis = (ms) => { let b = null; for (const m of ms) if (!b || m.need - m.got > b.need - b.got) b = m; return b; };
|
||||
for (const d of DYNASTIES) for (const cs of CONFIGS[d.id]) {
|
||||
const S = { noRoad: 0, byCount: 0, byGap: 0, byIdx: 0, gapSameAxis: 0, gapCrossAxis: 0 };
|
||||
const pairs = new Map();
|
||||
for (const t of TUPLES) for (const wang of WANGS) {
|
||||
const r = computeRank({ axes: t.axes, wang, chushen: cs }, d.id);
|
||||
if (r.channel) continue;
|
||||
S.noRoad += t.w;
|
||||
if (!r.nearest) continue;
|
||||
// 复算这一支的三级(★ 与 computeRank 的 sort 逐字同源),只为回答「是哪一级判的」
|
||||
const dd = DYNASTIES.find((x) => x.id === d.id);
|
||||
const rows = [];
|
||||
dd.channels.forEach((c, idx) => {
|
||||
const val = (k) => (k === 'wang' ? wang : t.axes[k]);
|
||||
const ms = Object.entries(c.gate || {}).filter(([k, n]) => val(k) < n).map(([k, n]) => ({ axis: k, need: n, got: Math.round(val(k)) }));
|
||||
rows.push({ name: c.name, ms, idx });
|
||||
});
|
||||
const pool = rows.filter((x) => r.blocked.some((b) => b.channel === x.name) && !r.blocked.find((b) => b.channel === x.name).byChushen);
|
||||
if (pool.length < 2) continue;
|
||||
const win = pool.find((x) => x.name === r.nearest);
|
||||
if (!win) continue;
|
||||
const rivals = pool.filter((x) => x !== win);
|
||||
if (rivals.every((x) => x.ms.length > win.ms.length)) { S.byCount += t.w; continue; }
|
||||
const same = rivals.filter((x) => x.ms.length === win.ms.length);
|
||||
if (same.every((x) => gapOf(x.ms) > gapOf(win.ms))) {
|
||||
S.byGap += t.w;
|
||||
for (const x of same) {
|
||||
const a = worstAxis(win.ms), b = worstAxis(x.ms);
|
||||
if (a && b) { if (a.axis === b.axis) S.gapSameAxis += t.w; else { S.gapCrossAxis += t.w; const k = `${a.axis}(差${a.need - a.got}) vs ${b.axis}(差${b.need - b.got})`; pairs.set(k, (pairs.get(k) || 0) + t.w); } }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (same.some((x) => gapOf(x.ms) === gapOf(win.ms))) S.byIdx += t.w;
|
||||
}
|
||||
if (!S.noRoad) continue;
|
||||
console.log(`\n── ${d.name}(chushen=${cs || '未答'})一条路都不开:${N(S.noRoad)} 格`);
|
||||
console.log(` nearest 由【差几条】判:${N(S.byCount)} 由【gapOf 缺口】判:${N(S.byGap)} 由【本表的编号】判:${N(S.byIdx)}`);
|
||||
if (S.byGap) console.log(` ★ gapOf 裁决时:【同轴 ${N(S.gapSameAxis)} 次】/【跨轴 ${N(S.gapCrossAxis)} 次】`);
|
||||
for (const [k, v] of [...pairs].sort((a, b) => b[1] - a[1]).slice(0, 5)) console.log(` ${pad(k, 40)} ${N(v)}`);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// ★ 最终读数:【全部出自引擎自己的 kai】—— 不是复算,是产物。
|
||||
import { DYNASTIES, computeRank, computeAxes } from '../src/dynasties.js';
|
||||
import { SUB_REGISTRY } from '../src/talents.js';
|
||||
const SUBS = SUB_REGISTRY.map((s) => s.id);
|
||||
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100];
|
||||
const S = {}; const P = {};
|
||||
for (const d of DYNASTIES) {
|
||||
S[d.id] = {}; P[d.id] = { noRoad: 0, hasKai: 0 };
|
||||
for (const c of d.channels) S[d.id][c.id] = { n: c.name, open: 0, walk: 0, not: 0, tier: 0, ceiling: 0, lex: 0, idx: 0, unsorted: 0 };
|
||||
}
|
||||
const t0 = Date.now();
|
||||
for (let m = 0; m < (1 << 16); m++) {
|
||||
const subs = []; for (let i = 0; i < 16; i++) if (m & (1 << i)) subs.push(SUBS[i]);
|
||||
const axes = computeAxes(subs);
|
||||
for (const wang of WANGS) for (const d of DYNASTIES) {
|
||||
const r = computeRank({ axes, wang }, d.id);
|
||||
if (!r.channel) { P[d.id].noRoad++; continue; }
|
||||
if (r.kai.length > 1) P[d.id].hasKai++;
|
||||
for (const k of r.kai) {
|
||||
const s = S[d.id][k.channelId];
|
||||
s.open++;
|
||||
if (k.walked) s.walk++; else { s.not++; s[k.lostAt]++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
const secs = ((Date.now() - t0) / 1000).toFixed(0);
|
||||
const TOT = (1 << 16) * WANGS.length;
|
||||
const pad = (s, n) => String(s).padEnd(n), num = (s, n) => String(s).padStart(n);
|
||||
console.log(`\n══ 引擎自产读数(r.kai):${TOT.toLocaleString()} × ${DYNASTIES.length} 朝 = ${(TOT * DYNASTIES.length).toLocaleString()} 格 / ${secs}s ══\n`);
|
||||
console.log(pad('朝/通道', 24) + num('开着', 9) + num('走了', 9) + num('开着没走', 10) + ' │' + num('tier', 8) + num('ceiling', 9) + num('lex', 9) + num('_idx', 9) + num('unsorted', 9));
|
||||
console.log('─'.repeat(97));
|
||||
const G = { tier: 0, ceiling: 0, lex: 0, idx: 0, unsorted: 0, not: 0 };
|
||||
for (const d of DYNASTIES) {
|
||||
for (const c of d.channels) {
|
||||
const s = S[d.id][c.id];
|
||||
for (const k of ['tier', 'ceiling', 'lex', 'idx', 'unsorted', 'not']) G[k] += s[k];
|
||||
console.log(pad(`${d.id}/${c.id}`, 24) + num(s.open.toLocaleString(), 9) + num(s.walk.toLocaleString(), 9) + num(s.not.toLocaleString(), 10) +
|
||||
' │' + num(s.tier.toLocaleString(), 8) + num(s.ceiling.toLocaleString(), 9) + num(s.lex.toLocaleString(), 9) + num(s.idx.toLocaleString(), 9) + num(s.unsorted, 9));
|
||||
}
|
||||
console.log(pad(` ↳ ${d.name}`, 24) + `一条路都不开 ${P[d.id].noRoad.toLocaleString()} 有【开着没走】的 ${P[d.id].hasKai.toLocaleString()} (${(P[d.id].hasKai / TOT * 100).toFixed(1)}%)\n`);
|
||||
}
|
||||
const H = Object.values(P).reduce((a, x) => a + x.hasKai, 0);
|
||||
console.log(`【总计】${(TOT * DYNASTIES.length).toLocaleString()} 格中 ${H.toLocaleString()} 格(${(H / (TOT * DYNASTIES.length) * 100).toFixed(1)}%)至少有一条【开着但没走】的路。`);
|
||||
console.log(`【输在哪一级】共 ${G.not.toLocaleString()} 条次:`);
|
||||
for (const k of ['ceiling', 'tier', 'lex', 'idx', 'unsorted'])
|
||||
console.log(` ${pad(k, 10)} ${num(G[k].toLocaleString(), 11)} (${(G[k] / G.not * 100).toFixed(1)}%)`);
|
||||
@@ -0,0 +1,40 @@
|
||||
// ★★★ 【nearest 是哪一级判的】—— ★ 读数【出自引擎自己的 nearestBy】,不是复算。
|
||||
// ⚠ 本项目铁律:「读数出自引擎自己的 kai(不是复算)」。第三刀先前那张表是【复算】的
|
||||
// (scripts/census-gap.mjs ④,它自己按 blocked 重建 pool)—— 而复算会【漏】:
|
||||
// 它在 pool<2 时跳过,于是把「本表真的有理由」那一级少报了。⇒ 这一份取代它。
|
||||
import { DYNASTIES, computeRank, computeAxes, AXIS_IDS } from '../src/dynasties.js';
|
||||
import { chushenOf } from '../src/chushen.js';
|
||||
import { SUB_REGISTRY } from '../src/talents.js';
|
||||
const SUBS = SUB_REGISTRY.map((s) => s.id);
|
||||
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100];
|
||||
const m = new Map();
|
||||
for (let b = 0; b < (1 << 16); b++) { const s = []; for (let i = 0; i < 16; i++) if (b & (1 << i)) s.push(SUBS[i]);
|
||||
const a = computeAxes(s); const k = AXIS_IDS.map((x) => a[x]).join(','); m.set(k, (m.get(k) || 0) + 1); }
|
||||
const T = [...m].map(([k, w]) => ({ axes: Object.fromEntries(k.split(',').map((v, i) => [AXIS_IDS[i], Number(v)])), w }));
|
||||
const CFG = {}; for (const d of DYNASTIES) { const x = chushenOf(d.id); CFG[d.id] = x ? [null, ...x.values.map((v) => v.id)] : [null]; }
|
||||
const N = (n) => n.toLocaleString();
|
||||
const pad = (s, n) => { s = String(s); let w = 0; for (const c of s) w += c.charCodeAt(0) > 0x2000 ? 2 : 1; return s + ' '.repeat(Math.max(1, n - w)); };
|
||||
console.log('\n══ 【无路者】nearest 是哪一级判的 —— 读数出自引擎自己的 nearestBy ══');
|
||||
console.log('══ 每朝每配置 524,288 个 profile(2,790 六轴取值 × 8 wang,带权)══\n');
|
||||
console.log(pad('朝', 8) + pad('出身', 10) + '无路者'.padStart(11) + '差几条(有理由)'.padStart(16) + 'gapOf(⚠不是距离)'.padStart(18) + '编号'.padStart(9) + ' 没有 nearest');
|
||||
console.log('─'.repeat(80));
|
||||
const G = { count: 0, gap: 0, idx: 0 };
|
||||
for (const d of DYNASTIES) for (const cs of CFG[d.id]) {
|
||||
const S = { noRoad: 0, count: 0, gap: 0, idx: 0, none: 0 };
|
||||
for (const t of T) for (const wang of WANGS) {
|
||||
const r = computeRank({ axes: t.axes, wang, chushen: cs }, d.id);
|
||||
if (r.channel) continue;
|
||||
S.noRoad += t.w;
|
||||
if (!r.nearest) { S.none += t.w; continue; }
|
||||
S[r.nearestBy] += t.w;
|
||||
}
|
||||
if (!S.noRoad) continue;
|
||||
for (const k of ['count', 'gap', 'idx']) G[k] += S[k];
|
||||
console.log(pad(d.name, 8) + pad(cs || '未答', 10) + N(S.noRoad).padStart(11) + N(S.count).padStart(16) +
|
||||
N(S.gap).padStart(18) + N(S.idx).padStart(9) + ' ' + (S.none ? N(S.none) : ''));
|
||||
}
|
||||
console.log('─'.repeat(80));
|
||||
console.log(pad('总计', 18) + ''.padStart(11) + N(G.count).padStart(16) + N(G.gap).padStart(18) + N(G.idx).padStart(9));
|
||||
const tot = G.count + G.gap + G.idx;
|
||||
console.log(`\n★ 「本表真的有理由」的那一级(差几条):${N(G.count)} / ${N(tot)} = ${(G.count / tot * 100).toFixed(1)}%`);
|
||||
console.log(`★★ 而其余 ${(100 - G.count / tot * 100).toFixed(1)}% 由【一个代号减出来的数】或【数组编号】决定。`);
|
||||
@@ -0,0 +1,39 @@
|
||||
// ★ 第八刀的代价读数:【放宽到老板的判据之后,有多少人会被多问一句「你走哪条」】
|
||||
// 老板上一刀要的那个数逐字:「⚠ 而必须先说清代价:实测汉有 169,984 个 profile(32.4%)
|
||||
// 至少有一条输在 _idx 的路 —— 即三个汉朝用户里有一个会被多问一句。」⇒ 现在这个数变了。
|
||||
import { DYNASTIES, computeRank, computeAxes } from '../src/dynasties.js';
|
||||
import { SUB_REGISTRY } from '../src/talents.js';
|
||||
const SUBS = SUB_REGISTRY.map((s) => s.id);
|
||||
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100];
|
||||
const TOT = (1 << 16) * WANGS.length;
|
||||
const P = {};
|
||||
for (const d of DYNASTIES) P[d.id] = { name: d.name, ask: 0, n2: 0, n3: 0, idxOnly: 0, hasLex: 0, noRoad: 0 };
|
||||
for (let m = 0; m < (1 << 16); m++) {
|
||||
const subs = []; for (let i = 0; i < 16; i++) if (m & (1 << i)) subs.push(SUBS[i]);
|
||||
const axes = computeAxes(subs);
|
||||
for (const wang of WANGS) for (const d of DYNASTIES) {
|
||||
const r = computeRank({ axes, wang }, d.id);
|
||||
const s = P[d.id];
|
||||
if (!r.channel) { s.noRoad++; continue; }
|
||||
const tie = r.zelu.tie || [];
|
||||
if (tie.length <= 1) continue;
|
||||
s.ask++;
|
||||
if (tie.length === 2) s.n2++; else s.n3++;
|
||||
const kinds = tie.filter((t) => !t.isDefault).map((t) => t.lostAt);
|
||||
if (kinds.every((k) => k === 'idx')) s.idxOnly++;
|
||||
if (kinds.includes('lex')) s.hasLex++;
|
||||
}
|
||||
}
|
||||
const N = (n) => n.toLocaleString();
|
||||
const pad = (s, n) => String(s).padEnd(n), num = (s, n) => String(s).padStart(n);
|
||||
console.log(`\n══ 【放宽后:有多少人会被多问一句「你走哪条」】每朝 ${N(TOT)} 个 profile ══\n`);
|
||||
console.log(pad('朝', 8) + num('要问', 10) + num('占比', 8) + num('2 选 1', 9) + num('≥3 选', 8) +
|
||||
num('★ 只含 idx', 12) + num('★ 含 lex', 10) + num('一条路都不开', 13));
|
||||
console.log('─'.repeat(80));
|
||||
let A = 0;
|
||||
for (const d of DYNASTIES) {
|
||||
const s = P[d.id]; A += s.ask;
|
||||
console.log(pad(s.name, 8) + num(N(s.ask), 10) + num((s.ask / TOT * 100).toFixed(1) + '%', 8) +
|
||||
num(N(s.n2), 9) + num(N(s.n3), 8) + num(N(s.idxOnly), 12) + num(N(s.hasLex), 10) + num(N(s.noRoad), 13));
|
||||
}
|
||||
console.log(`\n【总计】${N(TOT * DYNASTIES.length)} 格中 ${N(A)} 格(${(A / (TOT * DYNASTIES.length) * 100).toFixed(1)}%)会被多问一句。`);
|
||||
@@ -0,0 +1,77 @@
|
||||
// ★ 第二刀的验收读数:【打平的那些 case,tier 和 ceiling 真的都一样吗?】
|
||||
// 老板逐字:「⚠ 验证我这个判断:穷举里,打平的那些 case,tier 和 ceiling 真的都一样吗?
|
||||
// 有没有「tier 一样但 ceiling 不一样」的?那种不是打平,那种引擎有理由(位阶即杠杆)。
|
||||
// 跑出来,别信我。」
|
||||
// ⚠ 读数全部出自引擎自己的 r.kai(产物),不是复算。
|
||||
import { DYNASTIES, computeRank, computeAxes } from '../src/dynasties.js';
|
||||
import { SUB_REGISTRY } from '../src/talents.js';
|
||||
const SUBS = SUB_REGISTRY.map((s) => s.id);
|
||||
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100];
|
||||
|
||||
// 每一条【开着但没走】的路,按 (tier 关系, ceiling 关系) × lostAt 交叉列表
|
||||
const X = {}; // key: `${tierRel}|${ceilRel}` → { lostAt: n }
|
||||
const bump = (tr, cr, lostAt) => {
|
||||
const k = `${tr}|${cr}`;
|
||||
(X[k] || (X[k] = {}))[lostAt] = ((X[k] || {})[lostAt] || 0) + 1;
|
||||
};
|
||||
// zelu.options 的自检
|
||||
let optBad = 0, optN = 0, optTierDiff = 0, optCeilDiff = 0;
|
||||
// 引擎的排序器是不是一个自洽的全序:ranked[i] 必须真的赢 ranked[j] (i<j)
|
||||
let sortViol = 0;
|
||||
// 「tier 一样 && ceiling 一样」的总条次,按 lostAt 分
|
||||
const SAME = {};
|
||||
let totalNotWalked = 0;
|
||||
|
||||
const t0 = Date.now();
|
||||
for (let m = 0; m < (1 << 16); m++) {
|
||||
const subs = []; for (let i = 0; i < 16; i++) if (m & (1 << i)) subs.push(SUBS[i]);
|
||||
const axes = computeAxes(subs);
|
||||
for (const wang of WANGS) for (const d of DYNASTIES) {
|
||||
const r = computeRank({ axes, wang }, d.id);
|
||||
if (!r.channel) continue;
|
||||
const walked = r.kai.find((k) => k.walked);
|
||||
for (const k of r.kai) {
|
||||
if (k.walked) continue;
|
||||
totalNotWalked++;
|
||||
const tr = k.tier === walked.tier ? '=' : k.tier < walked.tier ? '<' : '>';
|
||||
const cr = k.ceiling === walked.ceiling ? '=' : k.ceiling < walked.ceiling ? '<' : '>';
|
||||
bump(tr, cr, k.lostAt);
|
||||
if (tr === '=' && cr === '=') SAME[k.lostAt] = (SAME[k.lostAt] || 0) + 1;
|
||||
}
|
||||
// zelu.tie:里面的每一条,tier 与 ceiling 必须与走的那条【全部相同】
|
||||
const opts = (r.zelu.tie || []).map((t) => t.channelId);
|
||||
if (opts.length > 1) {
|
||||
optN++;
|
||||
for (const id of opts) {
|
||||
const k = r.kai.find((x) => x.channelId === id);
|
||||
if (!k) { optBad++; continue; }
|
||||
if (k.tier !== walked.tier) optTierDiff++;
|
||||
if (k.ceiling !== walked.ceiling) optCeilDiff++;
|
||||
}
|
||||
}
|
||||
// 排序自洽性:kai 的次序就是 ranked 的次序
|
||||
for (let i = 0; i < r.kai.length; i++)
|
||||
for (let j = i + 1; j < r.kai.length; j++)
|
||||
if (r.kai[j].tier > r.kai[i].tier ||
|
||||
(r.kai[j].tier === r.kai[i].tier && r.kai[j].ceiling > r.kai[i].ceiling)) sortViol++;
|
||||
}
|
||||
}
|
||||
const secs = ((Date.now() - t0) / 1000).toFixed(0);
|
||||
const N = (n) => n.toLocaleString();
|
||||
console.log(`\n══ 【开着但没走】的路:(tier, ceiling) × 输在哪一级 ── ${N(totalNotWalked)} 条次 / ${secs}s ══\n`);
|
||||
console.log('tier ceiling │ ' + ['ceiling', 'tier', 'lex', 'idx', 'unsorted'].map((s) => s.padStart(10)).join('') + ' 合计');
|
||||
console.log('─'.repeat(78));
|
||||
for (const key of Object.keys(X).sort()) {
|
||||
const [tr, cr] = key.split('|');
|
||||
const row = X[key];
|
||||
const tot = Object.values(row).reduce((a, b) => a + b, 0);
|
||||
console.log(` ${tr} ${cr} │ ` +
|
||||
['ceiling', 'tier', 'lex', 'idx', 'unsorted'].map((s) => N(row[s] || 0).padStart(10)).join('') +
|
||||
' ' + N(tot).padStart(10));
|
||||
}
|
||||
console.log('\n★ 【tier 一样 && ceiling 一样】的条次,按输在哪一级分:');
|
||||
const sameTot = Object.values(SAME).reduce((a, b) => a + b, 0);
|
||||
for (const k of Object.keys(SAME)) console.log(` ${k.padEnd(10)} ${N(SAME[k]).padStart(11)} (${(SAME[k] / sameTot * 100).toFixed(1)}% of ${N(sameTot)})`);
|
||||
console.log(`\n★ zelu.options:${N(optN)} 格有 >1 个选项;` +
|
||||
`其中 tier 不同 ${N(optTierDiff)} 次、ceiling 不同 ${N(optCeilDiff)} 次、kai 里找不到 ${N(optBad)} 次`);
|
||||
console.log(`★ 排序自洽性(kai 次序里出现「后面的 tier/ceiling 反而更高」):${N(sortViol)} 次`);
|
||||
@@ -0,0 +1,50 @@
|
||||
// 一次性清理:上线前自测累积的数据(课堂/排期/孤儿自习/测试申请与预约)。
|
||||
// 大师课堂/排期是本轮新功能,目前库里全部课堂与排期均为自测生成 → 清空重置为干净初始态。
|
||||
// 大师本体(masters)与真实用户分身(twins)一律不动。
|
||||
// 用法:node scripts/cleanup-test-data.js
|
||||
import 'dotenv/config';
|
||||
import { connectDb, getDb, closeDb } from '../src/db.js';
|
||||
|
||||
// 测试用的申请/预约按提交名清理(都是自测占位)。
|
||||
const TEST_NAME = /QA|测试|验证|探测|阿测|走查|截图|类别验证|排期|徽匠老王|小林|徽州阿测/i;
|
||||
|
||||
async function main() {
|
||||
await connectDb();
|
||||
const db = getDb();
|
||||
|
||||
// 1) 课堂 + 排期:本功能刚上线,库里全是自测数据 → 清空。
|
||||
const classrooms = (await db.collection('classrooms').deleteMany({})).deletedCount;
|
||||
const schedules = (await db.collection('class_schedules').deleteMany({})).deletedCount;
|
||||
|
||||
// 2) 孤儿自习记录:twinId 指向已不存在的分身(历史级联漏删留下的)。
|
||||
const liveTwins = new Set(
|
||||
(await db.collection('twins').find({}).project({ _id: 1 }).toArray()).map((t) => String(t._id))
|
||||
);
|
||||
const learnings = await db.collection('twin_learnings').find({}).project({ _id: 1, twinId: 1 }).toArray();
|
||||
const orphanIds = learnings.filter((l) => !liveTwins.has(String(l.twinId))).map((l) => l._id);
|
||||
const orphanLearnings = orphanIds.length
|
||||
? (await db.collection('twin_learnings').deleteMany({ _id: { $in: orphanIds } })).deletedCount
|
||||
: 0;
|
||||
|
||||
// 3) 测试用的大师/合作申请、文旅预约(按测试名)。
|
||||
const masterApps = (await db.collection('master_applications').deleteMany({ name: TEST_NAME })).deletedCount;
|
||||
const partnerApps = (await db.collection('partner_applications').deleteMany({ name: TEST_NAME })).deletedCount;
|
||||
const bookings = (await db.collection('bookings').deleteMany({ name: TEST_NAME })).deletedCount;
|
||||
|
||||
// 4) 孤儿通知:userId 指向已不存在的用户。
|
||||
const liveUsers = new Set(
|
||||
(await db.collection('users').find({}).project({ _id: 1 }).toArray()).map((u) => String(u._id))
|
||||
);
|
||||
const notifs = await db.collection('notifications').find({}).project({ _id: 1, userId: 1 }).toArray();
|
||||
const orphanNotifIds = notifs.filter((n) => !liveUsers.has(String(n.userId))).map((n) => n._id);
|
||||
const orphanNotifs = orphanNotifIds.length
|
||||
? (await db.collection('notifications').deleteMany({ _id: { $in: orphanNotifIds } })).deletedCount
|
||||
: 0;
|
||||
|
||||
console.log('[cleanup] done:', JSON.stringify({
|
||||
classrooms, schedules, orphanLearnings, masterApps, partnerApps, bookings, orphanNotifs,
|
||||
}));
|
||||
await closeDb();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('[cleanup] failed:', e); process.exit(1); });
|
||||
@@ -0,0 +1,75 @@
|
||||
// 带外创建/重置【管理员账号】。
|
||||
//
|
||||
// 为什么需要它:超管资格 = .env 的 SUPER_ADMIN_EMAILS 白名单里的邮箱,谁注册了谁就是超管。
|
||||
// 但自助注册【已被安全策略禁止用白名单邮箱】(否则管理员邮箱尚无账号时,任何人都能抢注→拿到全部后台
|
||||
// 权限:改大师/生成深伪/结算分成/读 PII)。所以管理员账号只能【带外预置】——就是这个脚本。
|
||||
//
|
||||
// 用法(在服务器上,后端目录内):
|
||||
// ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='你的强密码' node scripts/create-admin.js
|
||||
//
|
||||
// 幂等:账号已存在 → 重置密码;不存在 → 新建。密码只用 scrypt 存 hash(与 routes/auth.js 完全一致),
|
||||
// 脚本【绝不打印密码】。
|
||||
import 'dotenv/config';
|
||||
import crypto from 'node:crypto';
|
||||
import { connectDb, getDb, closeDb } from '../src/db.js';
|
||||
|
||||
// 与 routes/auth.js 的 hash() 完全一致,否则建出来的号登不进去。
|
||||
function hash(password, salt) {
|
||||
return crypto.scryptSync(password, salt, 64).toString('hex');
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const email = String(process.env.ADMIN_EMAIL || '').trim().toLowerCase();
|
||||
const password = String(process.env.ADMIN_PASSWORD || '');
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
console.error('❌ 需要 ADMIN_EMAIL(合法邮箱)');
|
||||
process.exit(1);
|
||||
}
|
||||
if (password.length < 8) {
|
||||
console.error('❌ 需要 ADMIN_PASSWORD,且至少 8 位(管理员账号别用弱密码)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await connectDb();
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const existing = await db.collection('users').findOne({ email });
|
||||
|
||||
if (existing) {
|
||||
await db.collection('users').updateOne(
|
||||
{ _id: existing._id },
|
||||
{ $set: { salt, passwordHash: hash(password, salt), updatedAt: now } }
|
||||
);
|
||||
console.log(`✅ 账号已存在 → 已重置密码:${email}`);
|
||||
} else {
|
||||
const name = email.split('@')[0];
|
||||
await db.collection('users').insertOne({
|
||||
email,
|
||||
name,
|
||||
nickname: name,
|
||||
salt,
|
||||
passwordHash: hash(password, salt),
|
||||
entitlements: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
console.log(`✅ 已新建账号:${email}`);
|
||||
}
|
||||
|
||||
// 提醒:账号有了还不等于是超管——还要这个邮箱在 .env 的 SUPER_ADMIN_EMAILS 里。
|
||||
const whitelist = (process.env.SUPER_ADMIN_EMAILS || '')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
if (whitelist.includes(email)) {
|
||||
console.log(`✅ 该邮箱已在 SUPER_ADMIN_EMAILS 白名单里 → 登录后即为超管。`);
|
||||
} else {
|
||||
console.log(`⚠ 该邮箱【不在】SUPER_ADMIN_EMAILS 白名单里,现在登录只是普通账号。`);
|
||||
console.log(` 请把 .env 的 SUPER_ADMIN_EMAILS 设为 ${email} 并重启后端(grant-superadmin 工作流会做这步)。`);
|
||||
}
|
||||
console.log('(本脚本从不打印密码。请用你设置的密码登录,登录后建议立刻改成只有你知道的强密码。)');
|
||||
|
||||
await closeDb();
|
||||
}
|
||||
|
||||
run().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DYNASTIES } from '../src/dynasties.js';
|
||||
let n=0;
|
||||
for (const d of DYNASTIES) {
|
||||
console.log(`\n=== ${d.id} (${d.era} ${d.span}) tracks=${(d.tracks||[]).map(t=>t.id).join(',')}`);
|
||||
for (const c of d.channels) {
|
||||
n++;
|
||||
const x = c.xuan||{};
|
||||
console.log(` ${c.id.padEnd(18)} gate=${JSON.stringify(c.gate||{})} seat=${x.seat} kemu=${JSON.stringify((x.kemu||[]).map(k=>k.axis+'>='+k.need))} key=${JSON.stringify(x.key||[])} keySrc=${x.keySource===null?'null':'YES'} ceil=${c.ceiling?.tier} track=${c.track||'-'}`);
|
||||
console.log(` hollow=${!!c.hollow} hollowNote=${c.hollowNote?'Y':'-'} stigma=${!!c.stigma} stigmaNote=${c.stigmaNote?'Y':'-'} assim=${c.assimilation||0} assimNote=${c.assimilationNote?'Y':'-'} regular=${!!c.regular} cat=${JSON.stringify(c.category||'')} penalty=${c.penaltyIfNoWang===undefined?'-':c.penaltyIfNoWang} penaltyNote=${c.penaltyNote?'Y':'-'} absence=${(x.kemuAbsence||{}).kind||'-'}`);
|
||||
}
|
||||
}
|
||||
console.log('\nTOTAL channels =', n);
|
||||
@@ -0,0 +1,96 @@
|
||||
// 导出《天下·乐 全库总稿》——所有 1303 首,每首:编号(=文件名)/歌名/家族/版本/词/背景/Suno风格/1分钟截取。
|
||||
// 按家族(战役/名场面/她传/他传)+诗词(按年代)分。Word 可直接打开(.doc)。
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BK = path.resolve(__dirname, '..');
|
||||
const REPO = path.resolve(BK, '..');
|
||||
const LIB = JSON.parse(readFileSync(BK + '/src/data/music-library.json', 'utf8'));
|
||||
const shiciSrc = readFileSync(REPO + '/digital-me-web/src/data/shici.ts', 'utf8');
|
||||
const SHI = JSON.parse(shiciSrc.match(/export const SHI: Shi\[\] = (\[[\s\S]*\]);/)[1]);
|
||||
const ERAS = JSON.parse(shiciSrc.match(/export const SHI_ERAS:[^=]*= (\[[\s\S]*?\]);/)[1]);
|
||||
const shiById = {}; for (const p of SHI) shiById[p.id] = p;
|
||||
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const br = (s) => esc(s).replace(/\n/g, '<br/>');
|
||||
const fid = (id) => id.replace(/[^a-z0-9]+/gi, '_');
|
||||
const verLabel = (v) => v === 'instrumental' ? '纯配乐' : v === 'male' ? '男声' : v === 'female' ? '女声' : '化用曲';
|
||||
const VORD = { instrumental: 0, male: 1, female: 2, '': 3 };
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
function songBlock(s, idx) {
|
||||
const src = s.id.startsWith('shici:') ? shiById[s.id.slice(6)] : null;
|
||||
return `<div class="song">
|
||||
<div class="sh"><span class="idx">${idx}</span> <b class="tt">${esc(s.title)}</b> <span class="ver">${verLabel(s.vocal)}</span>${s.who ? ` <span class="who">视角/主角:${esc(s.who)}</span>` : ''}</div>
|
||||
<div class="fn">编号(文件名):<code>${esc(fid(s.id))}</code> → Suno 出曲后用此名(.mp3)发回即自动载入</div>
|
||||
${src ? `<div class="sec"><span class="lb">原诗</span><span class="src">${esc(src.author)} · ${esc(src.dynasty)}</span><div class="text">${br(src.text)}</div></div>
|
||||
${src.background ? `<div class="sec"><span class="lb">背景</span><span class="v">${br(src.background)}</span></div>` : ''}
|
||||
${src.kao ? `<div class="sec"><span class="lb">考证</span><span class="v">${br(src.kao)}</span></div>` : ''}` : ''}
|
||||
<div class="sec"><span class="lb">词</span><div class="lyr">${br(s.lyrics)}</div></div>
|
||||
<div class="sec"><span class="lb suno">Suno风格</span><div class="suno">${br(s.sunoStyle)}</div>${s.clip ? `<div class="clip">1分钟截取:${br(s.clip)}</div>` : ''}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let body = ''; let n = 0;
|
||||
// 锚点家族
|
||||
for (const fam of ['战役', '名场面', '她传', '他传']) {
|
||||
const rows = LIB.filter((s) => s.family === fam);
|
||||
if (!rows.length) continue;
|
||||
const bases = []; const bm = {};
|
||||
for (const s of rows) { const b = s.id.split('#')[0]; if (!bm[b]) { bm[b] = []; bases.push(b); } bm[b].push(s); }
|
||||
body += `<h2>${esc(fam)} <span class="cnt">${bases.length} 个 · ${rows.length} 首</span></h2>`;
|
||||
for (const b of bases) {
|
||||
const vs = bm[b].sort((a, c) => (VORD[a.vocal] ?? 9) - (VORD[c.vocal] ?? 9));
|
||||
body += `<h3>${esc(vs[0].title)}</h3>`;
|
||||
for (const s of vs) { n++; body += songBlock(s, n); }
|
||||
}
|
||||
}
|
||||
// 诗词按年代
|
||||
const shici = LIB.filter((s) => s.id.startsWith('shici:'));
|
||||
if (shici.length) {
|
||||
body += `<h2>诗词 <span class="cnt">${shici.length} 首</span></h2>`;
|
||||
for (const era of ERAS) {
|
||||
const rows = shici.filter((s) => { const p = shiById[s.id.slice(6)]; return p && p.eraSlug === era.slug; });
|
||||
if (!rows.length) continue;
|
||||
body += `<h3 class="era">${esc(era.label)} · ${rows.length} 首</h3>`;
|
||||
for (const s of rows) { n++; body += songBlock(s, n); }
|
||||
}
|
||||
}
|
||||
|
||||
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40"><head><meta charset="utf-8"/>
|
||||
<title>天下·乐 全库总稿</title>
|
||||
<style>
|
||||
body{ font-family:"宋体","SimSun","Microsoft YaHei",serif; color:#1e1a15; line-height:1.7; font-size:11pt; }
|
||||
h1{ font-size:22pt; text-align:center; color:#7a1f16; margin:0 0 3pt; }
|
||||
.meta{ text-align:center; color:#888; font-size:9pt; margin-bottom:6pt; }
|
||||
.legend{ text-align:center; color:#8a6d33; font-size:9pt; margin-bottom:18pt; }
|
||||
h2{ font-size:15pt; color:#9e2b1e; border-bottom:2px solid #9e2b1e; padding-bottom:3pt; margin:22pt 0 8pt; }
|
||||
h2 .cnt{ font-size:9pt; color:#999; font-weight:normal; }
|
||||
h3{ font-size:12.5pt; color:#7a5a20; margin:14pt 0 6pt; }
|
||||
h3.era{ color:#9e2b1e; }
|
||||
.song{ border:1px solid #ddd; border-radius:4px; padding:9pt 11pt; margin:0 0 10pt; page-break-inside:avoid; }
|
||||
.sh{ font-size:12pt; border-bottom:1px dashed #ccc; padding-bottom:3pt; margin-bottom:5pt; }
|
||||
.idx{ color:#9e2b1e; font-weight:bold; }
|
||||
.tt{ font-size:12.5pt; } .ver{ color:#2c6e49; font-size:9pt; margin-left:5pt; } .who{ color:#999; font-size:9pt; margin-left:5pt; }
|
||||
.fn{ font-size:9pt; color:#666; margin-bottom:5pt; }
|
||||
.fn code{ font-family:Consolas,monospace; background:#eee; padding:0 4pt; color:#7a1f16; }
|
||||
.sec{ margin:5pt 0; }
|
||||
.lb{ display:inline-block; background:#f2ead8; color:#8a6d33; font-size:8.5pt; padding:1pt 6pt; border-radius:3px; margin-right:6pt; }
|
||||
.lb.suno{ background:#e7efe8; color:#2c6e49; }
|
||||
.src{ color:#8a6d33; font-size:9pt; }
|
||||
.text{ padding-left:10pt; color:#444; margin-top:3pt; }
|
||||
.v{ color:#444; font-size:10pt; }
|
||||
.lyr{ margin-top:3pt; padding:6pt 10pt; background:#faf7ef; border-left:3px solid #c8a25e; color:#2a2a2a; font-size:10.5pt; }
|
||||
.suno{ margin-top:3pt; padding:6pt 10pt; background:#f3f8f4; border-left:3px solid #2c6e49; color:#234; font-size:9.5pt; font-family:Consolas,"Courier New",monospace; }
|
||||
.clip{ color:#2c6e49; font-size:9pt; margin-top:3pt; }
|
||||
</style></head><body>
|
||||
<h1>天下 · 乐 全库总稿</h1>
|
||||
<div class="meta">共 ${LIB.length} 首 · 母版「新东方·电影史诗·黯黑」· 生成 ${today}</div>
|
||||
<div class="legend">每首含 编号(=文件名) / 歌名 / 词 / 背景 / Suno风格 / 1分钟截取。<br/>命名闭环:Suno 出曲后用「编号」当文件名(.mp3)发回 → 超管上传页按名自动载入天下音乐。<br/>⚠ 本稿含 Suno 风格指令(制作配方),仅你自己看/喂 Suno,勿外传。</div>
|
||||
${body}
|
||||
</body></html>`;
|
||||
|
||||
const OUT = 'C:/Users/solut/Downloads/天下乐-全库总稿.doc';
|
||||
writeFileSync(OUT, '\uFEFF' + html, 'utf8');
|
||||
console.log('已导出:', OUT, '| 共', n, '首');
|
||||
@@ -0,0 +1,96 @@
|
||||
// 生成天下·乐 确权留痕交付物:①确权存证清单(哈希·供时间戳/区块链存证) ②确权登记清单.csv(供著作权登记) ③人类作者性说明.md ④确权档案-全.json(逐首)。
|
||||
// 落地:老板的 Downloads/天下确权留痕/ + 仓库 provenance/(git 时间戳锚点·仅哈希清单,不含母带)。
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildDossier, buildManifest, contentHash } from '../src/services/provenance.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BACKEND = path.resolve(__dirname, '..');
|
||||
const REPO = path.resolve(BACKEND, '..');
|
||||
|
||||
const songs = JSON.parse(readFileSync(path.join(BACKEND, 'src/data/music-library.json'), 'utf8'));
|
||||
|
||||
// 诗曲的公版源诗信息(shici.ts)
|
||||
const shiciSrc = readFileSync(path.join(REPO, 'digital-me-web/src/data/shici.ts'), 'utf8');
|
||||
const SHI = JSON.parse(shiciSrc.match(/export const SHI: Shi\[\] = (\[[\s\S]*\]);/)[1]);
|
||||
const shiById = {}; for (const p of SHI) shiById[p.id] = p;
|
||||
const sourceFor = (s) => {
|
||||
if (!String(s.id).startsWith('shici:')) return null;
|
||||
const p = shiById[s.id.slice('shici:'.length)];
|
||||
return p ? { title: p.title, author: p.author, dynasty: p.dynasty, text: p.text } : null;
|
||||
};
|
||||
|
||||
const generatedAt = new Date().toISOString();
|
||||
const OWNER = '(运营主体法律全称·待填)· digitme.cn';
|
||||
|
||||
// ── 交付目录 ──
|
||||
const OUT = 'C:/Users/solut/Downloads/天下确权留痕';
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const REPO_PROV = path.join(BACKEND, 'provenance');
|
||||
mkdirSync(REPO_PROV, { recursive: true });
|
||||
|
||||
// ① 存证清单(哈希) —— 落 Downloads + 仓库锚点
|
||||
const manifest = buildManifest(songs, { generatedAt });
|
||||
writeFileSync(path.join(OUT, '确权存证清单.json'), JSON.stringify(manifest, null, 2), 'utf8');
|
||||
writeFileSync(path.join(REPO_PROV, '确权存证清单.json'), JSON.stringify(manifest, null, 2), 'utf8');
|
||||
|
||||
// ② 逐首确权档案(全) —— 登记/律师用
|
||||
const dossiers = songs.map((s) => buildDossier(s, { source: sourceFor(s), owner: OWNER }));
|
||||
writeFileSync(path.join(OUT, '确权档案-全.json'), JSON.stringify({ generatedAt, count: dossiers.length, dossiers }, null, 2), 'utf8');
|
||||
|
||||
// ③ 登记清单 CSV
|
||||
const esc = (v) => `"${String(v == null ? '' : v).replace(/"/g, '""')}"`;
|
||||
const anchorCount = songs.filter((s) => !String(s.id).startsWith('shici:')).length;
|
||||
const shiciCount = songs.length - anchorCount;
|
||||
const rows = [['曲id', '标题', '家族', '词的类别', '化用来源(公版)', '著作权人', '内容哈希SHA256'].map(esc).join(',')];
|
||||
for (const s of songs) {
|
||||
const src = sourceFor(s);
|
||||
rows.push([
|
||||
s.id, s.title, s.family,
|
||||
src ? '化用公版古诗(独创改写受保护)' : '完全原创词',
|
||||
src ? `${src.title}·${src.author}·${src.dynasty}(公有领域)` : '—',
|
||||
OWNER, contentHash(s),
|
||||
].map(esc).join(','));
|
||||
}
|
||||
writeFileSync(path.join(OUT, '确权登记清单.csv'), '\uFEFF' + rows.join('\r\n'), 'utf8'); // BOM 便于 Excel 识别 UTF-8
|
||||
|
||||
// ④ 人类作者性说明.md
|
||||
const narrative = `# 天下·乐 曲库 · 人类作者性与确权说明
|
||||
|
||||
> 生成时点:${generatedAt}
|
||||
> 曲目总数:${songs.length}(完全原创词 ${anchorCount} 首 · 化用公版古诗 ${shiciCount} 首)
|
||||
> 著作权人:${OWNER}
|
||||
|
||||
## 一、作品构成与权利定位(三块资产,强弱不同)
|
||||
1. **歌词(文字作品)** —— 由平台确定创作方向、进行原创写作或对**公有领域**古诗的现代口语化用/重写,并多轮编辑定稿。**人类独创性最强,是最稳的著作权主张。**
|
||||
2. **成品录音(录音制品/录音制作者权·邻接权)** —— 平台作为录音制品制作者,对**成品音频文件**依法享有复制、发行、信息网络传播权。**对“盗播/盗传我方母带文件”最能打,且基本不受 AI 独创性之争影响。**
|
||||
3. **曲(音乐作品)** —— 借助 Suno(平台持最高级付费会员,其服务条款授予输出的所有权/商用权)在**人类创作方向、风格选择、编排与甄选**下生成。纯 AI 生成成分的可版权性依个案“人类独创性投入”认定,**不对外主张旋律独占**。
|
||||
|
||||
## 二、人类独创性投入(可版权性的证据基础)
|
||||
每首歌均有以下人类智力投入,记录见《确权档案-全.json》逐首字段:
|
||||
- **创作方向 / 风格 brief**:由人确定题材、情绪、世界级音乐流派方向;
|
||||
- **歌词创作与化用改写**:原创歌词;或对公版古诗做结构重组、口语化重写(化用部分为独创表达);
|
||||
- **生成指令(prompt)**:投喂 Suno 的详细创作指令(配器/节奏/编曲/音色),为人类创作意图的具体化;
|
||||
- **甄选**:从多版本生成结果中人工甄选定稿、淘汰其余;
|
||||
- **编排与截取决策**:三版本(纯配乐/男声/女声)、正常版/1分钟版的编排与截取。
|
||||
|
||||
## 三、来源合法性(反向侵权自查结论)
|
||||
- 化用的古诗**全部为公有领域**(诗词篇作者最晚为龚自珍,卒于 1841 年,远逾“作者卒后 50 年”保护期);经核查**无近现代(作者卒于 1976 年及以后)在保护期内的诗人作品**,不存在使用他人在保护期作品的情形。
|
||||
- 完全原创词不涉及第三方作品。
|
||||
|
||||
## 四、确权动作(按此清单执行)
|
||||
1. **存证(现在做·最快)**:对《确权存证清单.json》做一次**可信时间戳/区块链存证**(如联合信任时间戳/至信链/蚂蚁链/司法链),即以最小成本固定全库“在先创作”时点;masterHash 为全库汇总指纹。
|
||||
2. **登记(分批)**:按《确权登记清单.csv》,优先登记**变现主打 + 最易被盗**曲目的**歌词(文字作品)**与**成品录音(录音制品)**;曲以弱证据心态登记。
|
||||
3. **权利来源留存**:保存 Suno 最高级会员凭证 + 生成时点的 ToS 版本快照 + 每首生成记录(补权属链)。
|
||||
4. **技术配套(已做/待补)**:母带私有存储 + 会员短效签名 URL(已做);**取证水印**(按会员打唯一隐式水印,泄漏可追人)待补。
|
||||
|
||||
> ⚠ 法律定性以知识产权律师意见为准。AIGC 可版权性在中国为个案裁量、尚未定论;本材料用于把“谁、何时、拥有什么、有何人类投入”固化为证据,不等于对“曲”能取得独占版权。
|
||||
`;
|
||||
writeFileSync(path.join(OUT, '人类作者性说明.md'), narrative, 'utf8');
|
||||
|
||||
console.log('确权留痕已生成:');
|
||||
console.log(' 曲目', songs.length, '(原创词', anchorCount, '· 化用公版', shiciCount, ')');
|
||||
console.log(' masterHash', manifest.masterHash_SHA256);
|
||||
console.log(' → Downloads/天下确权留痕/:确权存证清单.json · 确权登记清单.csv · 人类作者性说明.md · 确权档案-全.json');
|
||||
console.log(' → 仓库 provenance/确权存证清单.json(git 时间戳锚点)');
|
||||
@@ -0,0 +1,111 @@
|
||||
// 生成《天下·乐 确权操作指南》Word 版(.doc·HTML)到老板 Downloads/天下确权留痕/。排版友好、可照做、带勾选清单。
|
||||
import { writeFileSync } from 'node:fs';
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40"><head><meta charset="utf-8"/>
|
||||
<title>天下·乐 确权操作指南</title>
|
||||
<style>
|
||||
body{ font-family:"宋体","SimSun","Microsoft YaHei",serif; color:#1e1a15; line-height:1.75; font-size:11pt; }
|
||||
h1{ font-size:22pt; text-align:center; color:#7a1f16; margin:0 0 2pt; letter-spacing:1pt; }
|
||||
.sub{ text-align:center; color:#8a6d33; font-size:11pt; margin-bottom:2pt; }
|
||||
.meta{ text-align:center; color:#999; font-size:9pt; margin-bottom:16pt; }
|
||||
h2{ font-size:14.5pt; color:#9e2b1e; border-bottom:2px solid #9e2b1e; padding-bottom:3pt; margin:20pt 0 8pt; }
|
||||
h3{ font-size:12pt; color:#8a6d33; margin:12pt 0 4pt; }
|
||||
p{ margin:5pt 0; }
|
||||
.box{ border:1px solid #d8cdb0; background:#faf7ef; border-radius:5px; padding:9pt 12pt; margin:8pt 0; }
|
||||
.tip{ border-left:4px solid #c8a25e; background:#faf6ea; padding:6pt 12pt; margin:8pt 0; }
|
||||
.warn{ border-left:4px solid #c0392b; background:#fdf1ef; padding:6pt 12pt; margin:8pt 0; color:#7a1f16; }
|
||||
.ck{ margin:4pt 0; }
|
||||
.ck b{ color:#9e2b1e; }
|
||||
table{ border-collapse:collapse; width:100%; margin:8pt 0; font-size:10pt; }
|
||||
th,td{ border:1px solid #d8cdb0; padding:5pt 8pt; text-align:left; vertical-align:top; }
|
||||
th{ background:#f2ead8; color:#7a5a20; }
|
||||
code{ font-family:Consolas,"Courier New",monospace; background:#eee; padding:0 3pt; font-size:9.5pt; }
|
||||
.big{ font-size:12.5pt; color:#7a1f16; font-weight:bold; }
|
||||
.step{ font-size:13pt; color:#fff; background:#9e2b1e; border-radius:50%; padding:1pt 8pt; margin-right:6pt; }
|
||||
.small{ color:#888; font-size:9pt; }
|
||||
</style></head><body>
|
||||
|
||||
<h1>天下 · 乐 确权操作指南</h1>
|
||||
<div class="sub">你做的歌(原创词 × Suno 曲)· 怎么把知识产权保护好</div>
|
||||
<div class="meta">照着做即可 · ${today} · 共 1303 首(作品 865 个)</div>
|
||||
|
||||
<div class="tip"><b>一句话</b>:你的护城河 = <b>词(原创)+ 成品录音(邻接权,专治盗播盗传母带)</b>;<b>曲(纯 AI)不主张旋律独占</b>。“能商用 ≠ 能独占”。把力气压在词和录音上。</div>
|
||||
|
||||
<h2>你手上已有的文件(本文件夹内)</h2>
|
||||
<table>
|
||||
<tr><th>文件</th><th>用途</th></tr>
|
||||
<tr><td><b>确权存证清单.json</b></td><td>全库 1303 首内容指纹 + 汇总 masterHash。<b>第①步存证就传它。</b></td></tr>
|
||||
<tr><td><b>确权登记清单.csv</b></td><td>1303 行,分批著作权登记用。</td></tr>
|
||||
<tr><td><b>人类作者性说明.md</b></td><td>证明“人类独创性投入”的说明书,给律师 / 登记用。</td></tr>
|
||||
<tr><td><b>确权档案-全.json</b></td><td>逐首完整档案(词 / 化用来源 / 创作投入 / 哈希)。</td></tr>
|
||||
<tr><td><b>本指南(.doc)</b></td><td>你正在看的这份。</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="box"><b>总顺序:</b> ① 存证(今天·10 分钟)→ ② 存 Suno 权利来源(今天·10 分钟)→ ③ 著作权登记(本周起·分批)→ ④ 找律师定稿(并行)。<br/>①② 今天做完,“在先创作 + 权利来源”证据链当天立住;③④ 把它做成法律级、可打官司。</div>
|
||||
|
||||
<h2><span class="step">1</span> 存证 —— 今天最优先,10 分钟锁住全库时点</h2>
|
||||
<p><b>目的</b>:给全库盖一个不可篡改的“<b>这些歌在 ${today} 已经存在</b>”时间戳。以后谁抄你,你能证明你在先。</p>
|
||||
<h3>做法(任选一个平台,从简单的 SaaS 入手)</h3>
|
||||
<p class="big">推荐 A · 区块链存证 SaaS(界面最简单)</p>
|
||||
<div class="ck">☐ <b>1)</b> 打开 <b>保全网(baoquan.com)</b> / 易保全 / 版权家 之一,注册并做实名认证。</div>
|
||||
<div class="ck">☐ <b>2)</b> 找“<b>文件存证 / 数据存证 / 版权存证</b>”功能。</div>
|
||||
<div class="ck">☐ <b>3)</b> 上传 <code>确权存证清单.json</code> <b>这一个文件</b>,提交。</div>
|
||||
<div class="ck">☐ <b>4)</b> 下载它给你的<b>存证证书</b>(含哈希、时间、区块链交易号),存回本文件夹。</div>
|
||||
<p class="big">备选 B · 可信时间戳</p>
|
||||
<p>联合信任时间戳服务中心(<b>tsa.cn</b>,国家授时中心背书)。注册后对文件申请时间戳,拿证书文件存好。法院认可度高,界面略传统。</p>
|
||||
<div class="tip"><b>关键点</b><br/>• 你传的是<b>哈希清单</b>、不是母带 —— 不泄露任何音频,安全。<br/>• 这一件存证 = 固定全库 1303 首的在先时点(清单里每首都有指纹,能一一对应回内容)。<br/>• <b>主打曲另加做</b>:挑十几首最看重、最怕被抄的,把它的<b>歌词全文 + 音频</b>也各单独存一次(更硬,维权时不用解释“哈希怎么对应内容”)。要哪些告诉我,我单独打包成待传文件。</div>
|
||||
<p class="small">费用量级:SaaS 单条几元~几十元 / 或几百~上千元一年套餐;时间戳按条计(元级)。以平台当前价为准。</p>
|
||||
|
||||
<h2><span class="step">2</span> 存 Suno 权利来源 —— 今天,10 分钟</h2>
|
||||
<p><b>目的</b>:证明“曲的商用权来自你持最高级会员、由 Suno 条款授予”。条款会改版,必须<b>固定你生成时那一版</b>。</p>
|
||||
<div class="ck">☐ <b>1)</b> 截图 Suno 账号页,显示你的<b>会员等级</b>(Pro / Premier)和付费记录。</div>
|
||||
<div class="ck">☐ <b>2)</b> 打开 Suno 的 <b>Terms of Service</b> 页,<b>整页存成 PDF</b>(浏览器打印→另存 PDF),文件名带日期,如 <code>Suno-ToS-${today}.pdf</code>,存进本文件夹。</div>
|
||||
<div class="ck">☐ <b>3)</b> 每首在 Suno 库里的<b>生成记录</b>(生成时间 / 曲目 ID)能导出就导出、不能就截图归档。</div>
|
||||
<div class="ck">☐ <b>4)</b> 会员<b>别断档</b>(权利来源要持续有效)。</div>
|
||||
|
||||
<h2><span class="step">3</span> 著作权登记 —— 本周开始,分批(不必一次全登)</h2>
|
||||
<p><b>目的</b>:拿国家登记证书,形成“权属推定”——维权、下架、授权、融资都好用。</p>
|
||||
<h3>去哪登(建议先看本地)</h3>
|
||||
<p>• <b>中国版权保护中心</b>:ccopyright.com.cn 网上登记系统(全国通用)。<br/>• <b>地方版权局 / 登记中心</b>:常更快、更便宜、有的有财政补贴。你在成都,可看<b>四川省版权登记</b>。</p>
|
||||
<h3>登什么、按什么类别(登对了才最能打)</h3>
|
||||
<table>
|
||||
<tr><th>成分</th><th>登记类别</th><th>优先级</th></tr>
|
||||
<tr><td>歌词</td><td>文字作品</td><td><b>第一优先</b>(人类独创性最强、最稳)</td></tr>
|
||||
<tr><td>成品录音</td><td>录音制品 / 录音制作者</td><td><b>第二优先</b>(专治盗播盗传母带)</td></tr>
|
||||
<tr><td>曲</td><td>音乐作品</td><td>弱证据心态登,<b>别指望旋律独占</b></td></tr>
|
||||
</table>
|
||||
<p><b>怎么分批</b>:按 CSV 挑<b>变现主打 + 最怕被抄</b>的十几首先登全套(词+录音),其余先靠第①步存证兜底,回头再分批登。</p>
|
||||
<div class="warn"><b>⚠ AIGC 怎么填,务必让律师把关这一栏</b><br/>著作权人填你的公司 / 个人;涉及 AI 辅助的要<b>如实、但突出人类投入</b>(创作方向、原创词 / 化用改写、prompt、甄选、编排——《人类作者性说明》就是给这个用的)。填法不当可能导致登记被撤、或维权时被反咬。</div>
|
||||
<p class="small">费用/时间量级:官费每件几十~几百元;正常审核约 30 个工作日、可加急;找代理另加服务费。以受理机构当前标准为准。</p>
|
||||
|
||||
<h2><span class="step">4</span> 找律师(和 ②③ 并行)</h2>
|
||||
<p>把本文件夹一起发给他,让他做三件:</p>
|
||||
<div class="ck">☐ <b>权属意见书</b>:Suno 输出在中国的权利衔接 + 人类独创性如何满足(可引北京互联网法院 2023 AI 文生图案)。</div>
|
||||
<div class="ck">☐ <b>登记口径</b>:AIGC 部分在登记表里怎么如实又稳妥地写。</div>
|
||||
<div class="ck">☐ <b>协议起草</b>:用户协议(用户只得使用权、禁商用转售)+ 平台版权声明 + AIGC 使用说明。</div>
|
||||
<p class="small">费用量级:意见书 + 协议起草大致几千~上万元;维权 / 公证按需另算。</p>
|
||||
|
||||
<h2>三块资产的法律地位(想清楚,别当一首歌一个版权)</h2>
|
||||
<table>
|
||||
<tr><th>资产</th><th>权利类型</th><th>强弱 / 说明</th></tr>
|
||||
<tr><td><b>词</b>(原创 + 化用公版古诗)</td><td>文字作品 · 著作权</td><td><b>最强</b>(前提:<b>经你实质审改 / 甄选</b>,人类独创性才立得住)</td></tr>
|
||||
<tr><td><b>成品录音</b></td><td>录音制品 · 录音制作者权(邻接权)</td><td><b>最能打盗播盗传</b>;依据是制作投入,<b>不看独创性</b>,基本不受 AI 之争影响</td></tr>
|
||||
<tr><td><b>曲</b>(Suno 生成)</td><td>音乐作品 · 著作权</td><td><b>最弱</b>;纯 AI 成分重,可版权性依个案人类投入认定,<b>不主张旋律独占</b></td></tr>
|
||||
</table>
|
||||
|
||||
<div class="warn"><b>AIGC 可版权性 —— 诚实提醒</b><br/>中国是个案裁量、尚未定论(北京互联网法院 2023 案支持“有独创性人类投入即受保护”,但属个案);美国不给纯 AI 生成物登记。<b>“AI 帮你写” ≠ 你自动拿到独占版权</b> —— 能不能,取决于你这边的人类创作投入。<b>你现在逐首审改这 646 首诗词,正是把“AI 初稿”变成“你主导创作的作品”的关键动作</b>:审改要留痕,我把改动记进《人类作者性说明》。</div>
|
||||
|
||||
<h2>反向侵权自查(结论:安全)</h2>
|
||||
<p>诗词篇 646 首<b>全部为先秦至明清古典诗词</b>,最晚的作者龚自珍卒于 1841 年,远逾“作者卒后 50 年”保护期,<b>均为公有领域</b>;经核查<b>无近现代(作者卒于 1976 年及以后)在保护期内的诗人作品</b>,<b>无需下架</b>。源诗公版、你的化用原创,两个方向都干净。</p>
|
||||
|
||||
<h2>还差的一刀(技术 · 等你 mp3 到位再说)</h2>
|
||||
<p><b>取证水印</b>:每个会员 / 每次下载打一个听不见的唯一水印,一旦有人把满血曲传出去,你能<b>追到是谁泄的</b>。开源方案(audiowmark)我可以接进现有会员下载链路,只占共享服务器一点算力 / 磁盘,你发话我就做。</p>
|
||||
|
||||
<div class="warn">本指南为操作梳理,<b>法律定性以知识产权律师意见为准</b>。存证 / 登记解决“谁、何时、拥有什么、有何人类投入”的举证;能否就“曲”取得独占版权由法院个案认定。</div>
|
||||
|
||||
</body></html>`;
|
||||
|
||||
const OUT = 'C:/Users/solut/Downloads/天下确权留痕/天下确权操作指南.doc';
|
||||
writeFileSync(OUT, '\uFEFF' + html, 'utf8');
|
||||
console.log('已生成 Word 版:', OUT);
|
||||
@@ -0,0 +1,80 @@
|
||||
// 导出《天下·拾遗 诗词篇 全稿(审阅版)》——供老板离线通读审定。每首:原诗+作者其时+考证+魂句+化用曲词。
|
||||
// 输出 Word 可直接打开的 .doc(HTML)到 Downloads。
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO = path.resolve(__dirname, '../..');
|
||||
const BACKEND = path.resolve(__dirname, '..');
|
||||
const src = readFileSync(path.join(REPO, 'digital-me-web/src/data/shici.ts'), 'utf8');
|
||||
const SHI = JSON.parse(src.match(/export const SHI: Shi\[\] = (\[[\s\S]*\]);/)[1]);
|
||||
const ERAS = JSON.parse(src.match(/export const SHI_ERAS:[^=]*= (\[[\s\S]*?\]);/)[1]);
|
||||
// Suno 风格指令在后端曲库(前端已剥配方)。审阅稿给老板自己看+喂Suno,带上。id: shici.ts 的 p.id ↔ music-library 的 shici:<id>
|
||||
const LIB = JSON.parse(readFileSync(path.join(BACKEND, 'src/data/music-library.json'), 'utf8'));
|
||||
const sunoMap = {}; const clipMap = {};
|
||||
for (const s of LIB) if (String(s.id).startsWith('shici:')) { const k = s.id.slice('shici:'.length); sunoMap[k] = s.sunoStyle || ''; clipMap[k] = s.clip || ''; }
|
||||
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const br = (s) => esc(s).replace(/\n/g, '<br/>');
|
||||
const generatedAt = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let body = '';
|
||||
let n = 0;
|
||||
for (const era of ERAS) {
|
||||
const list = SHI.filter((p) => p.eraSlug === era.slug);
|
||||
if (!list.length) continue;
|
||||
body += `<h2 class="era">${esc(era.label)} <span class="cnt">${list.length} 首</span></h2>`;
|
||||
for (const p of list) {
|
||||
n++;
|
||||
body += `<div class="poem">
|
||||
<div class="ph"><span class="idx">${n}</span> <b class="tt">${esc(p.title)}</b> <span class="au">${esc(p.author)}</span> <span class="dy">${esc(p.dynasty)}</span> <span class="pid">[${esc(p.id)}]</span></div>
|
||||
<div class="sec"><span class="lb">原诗</span><div class="text">${br(p.text)}</div></div>
|
||||
${p.authorBrief ? `<div class="sec"><span class="lb">作者其时</span><div class="v">${br(p.authorBrief)}</div></div>` : ''}
|
||||
${p.background ? `<div class="sec"><span class="lb">创作背景</span><div class="v">${br(p.background)}</div></div>` : ''}
|
||||
${p.kao ? `<div class="sec"><span class="lb">考证</span><div class="v">${br(p.kao)}</div></div>` : ''}
|
||||
${p.soulLine ? `<div class="sec"><span class="lb">魂句</span><div class="v soul">${br(p.soulLine)}</div></div>` : ''}
|
||||
${p.song && p.song.lyrics ? `<div class="sec"><span class="lb">化用曲词</span><div class="lyr">${br(p.song.lyrics)}</div>${p.song.embedded ? `<div class="emb">嵌入原句:${br(p.song.embedded)}</div>` : ''}</div>` : '<div class="sec nolyr">(此首暂无化用曲词)</div>'}
|
||||
${sunoMap[p.id] ? `<div class="sec"><span class="lb suno">Suno 风格指令</span><div class="suno">${br(sunoMap[p.id])}</div>${clipMap[p.id] ? `<div class="clip">1分钟截取:${br(clipMap[p.id])}</div>` : ''}</div>` : '<div class="sec nolyr">(此首暂无 Suno 风格指令)</div>'}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40"><head><meta charset="utf-8"/>
|
||||
<title>天下·拾遗 诗词篇 全稿(审阅版)</title>
|
||||
<style>
|
||||
body{ font-family:"宋体","SimSun","Microsoft YaHei",serif; color:#1a1a1a; line-height:1.7; font-size:11pt; }
|
||||
h1{ font-size:22pt; text-align:center; margin:0 0 4pt; }
|
||||
.meta{ text-align:center; color:#666; font-size:9pt; margin-bottom:18pt; }
|
||||
.legend{ text-align:center; color:#8a6d33; font-size:9pt; margin-bottom:20pt; }
|
||||
h2.era{ font-size:15pt; color:#9e2b1e; border-bottom:2px solid #9e2b1e; padding-bottom:3pt; margin:22pt 0 10pt; }
|
||||
.era .cnt{ font-size:9pt; color:#999; font-weight:normal; }
|
||||
.poem{ border:1px solid #ddd; border-radius:4px; padding:10pt 12pt; margin:0 0 12pt; page-break-inside:avoid; }
|
||||
.ph{ font-size:12pt; margin-bottom:6pt; border-bottom:1px dashed #ccc; padding-bottom:4pt; }
|
||||
.idx{ color:#9e2b1e; font-weight:bold; }
|
||||
.tt{ font-size:13pt; }
|
||||
.au{ color:#8a6d33; margin-left:6pt; }
|
||||
.dy{ color:#999; font-size:9pt; margin-left:6pt; }
|
||||
.pid{ color:#bbb; font-size:8pt; font-family:Consolas,monospace; float:right; }
|
||||
.sec{ margin:6pt 0; }
|
||||
.lb{ display:inline-block; background:#f2ead8; color:#8a6d33; font-size:8.5pt; padding:1pt 6pt; border-radius:3px; margin-right:6pt; }
|
||||
.text{ margin-top:4pt; padding-left:10pt; color:#333; }
|
||||
.v{ display:inline; color:#444; font-size:10pt; }
|
||||
.soul{ color:#9e2b1e; }
|
||||
.lyr{ margin-top:4pt; padding:6pt 10pt; background:#faf7ef; border-left:3px solid #c8a25e; color:#2a2a2a; font-size:10.5pt; }
|
||||
.emb{ color:#8a6d33; font-size:9pt; margin-top:3pt; }
|
||||
.lb.suno{ background:#e7efe8; color:#2c6e49; }
|
||||
.suno{ margin-top:4pt; padding:6pt 10pt; background:#f3f8f4; border-left:3px solid #2c6e49; color:#234; font-size:10pt; font-family:Consolas,"Courier New",monospace; }
|
||||
.clip{ color:#2c6e49; font-size:9pt; margin-top:3pt; }
|
||||
.nolyr{ color:#c0392b; font-size:9pt; }
|
||||
</style></head><body>
|
||||
<h1>天下 · 拾遗 · 诗词篇 全稿(审阅版)</h1>
|
||||
<div class="meta">共 ${SHI.length} 首 · 先秦至明清 · 每首含【化用曲词 + Suno 风格指令】· 生成 ${generatedAt} · 供离线审定</div>
|
||||
<div class="legend">审阅用法:要改的请记下左上角编号或 [id](如 [tang-012]),回头告诉我批量改;改定后再一起做确权。<br/>⚠ 本稿含 Suno 风格指令(=制作配方,仅你自己看/喂 Suno 用,前端已对用户隐藏,勿外传)。</div>
|
||||
${body}
|
||||
</body></html>`;
|
||||
|
||||
const OUT = 'C:/Users/solut/Downloads/天下诗词篇全稿-审阅版-含Suno风格.doc';
|
||||
writeFileSync(OUT, '\uFEFF' + html, 'utf8');
|
||||
console.log('已导出:', OUT);
|
||||
console.log('共', SHI.length, '首;有化用曲词的', SHI.filter((p) => p.song && p.song.lyrics).length, '首');
|
||||
@@ -0,0 +1,98 @@
|
||||
// 端到端用户走查:进站→逛大师墙→大师详情→和大师聊→文旅→路线→报名→注册→养分身→登出。
|
||||
// 每步截图 + 抓控制台报错/失败请求。用法:node scripts/journey.mjs <shotsDir>
|
||||
// 前置:chrome 带 --remote-debugging-port=9222 已打开 http://localhost:5173/
|
||||
import WebSocket from 'ws';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
const OUT = process.argv[2];
|
||||
const BASE = 'http://localhost:5173';
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const uniqEmail = `walk${Date.now()}@test.com`;
|
||||
|
||||
async function targetWs() {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const ts = await (await fetch('http://localhost:9222/json/list')).json();
|
||||
const t = ts.find((x) => x.type === 'page' && /localhost:5173/.test(x.url));
|
||||
if (t) return t.webSocketDebuggerUrl;
|
||||
await wait(300);
|
||||
}
|
||||
throw new Error('no 5173 target');
|
||||
}
|
||||
|
||||
const report = [];
|
||||
let curStep = 'init';
|
||||
const errs = []; // {step, kind, detail}
|
||||
|
||||
async function main() {
|
||||
const ws = await targetWs();
|
||||
const sock = new WebSocket(ws, { perMessageDeflate: false, maxPayload: 64 * 1024 * 1024 });
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((resolve) => { const mid = ++id; pending.set(mid, resolve); sock.send(JSON.stringify({ id: mid, method, params })); });
|
||||
await new Promise((r) => sock.on('open', r));
|
||||
sock.on('message', (buf) => {
|
||||
const m = JSON.parse(buf.toString());
|
||||
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); return; }
|
||||
if (m.method === 'Runtime.consoleAPICalled' && (m.params.type === 'error')) {
|
||||
const txt = (m.params.args || []).map((a) => a.value || a.description || '').join(' ').slice(0, 160);
|
||||
if (txt) errs.push({ step: curStep, kind: 'console.error', detail: txt });
|
||||
}
|
||||
if (m.method === 'Runtime.exceptionThrown') {
|
||||
errs.push({ step: curStep, kind: 'exception', detail: (m.params.exceptionDetails?.exception?.description || m.params.exceptionDetails?.text || '').slice(0, 160) });
|
||||
}
|
||||
if (m.method === 'Network.responseReceived') {
|
||||
const st = m.params.response?.status, u = m.params.response?.url || '';
|
||||
if (st >= 400 && /\/api\//.test(u)) errs.push({ step: curStep, kind: 'http ' + st, detail: u.replace('http://localhost:3701', '') });
|
||||
}
|
||||
});
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable');
|
||||
|
||||
const evalJs = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true }))?.result?.value;
|
||||
const nav = async (path) => { await send('Page.navigate', { url: BASE + path }); await wait(2600); };
|
||||
const clickText = (txt) => evalJs(`(()=>{const t=${JSON.stringify(txt)}.replace(/\\s+/g,'');const el=[...document.querySelectorAll('a,button,[role=button],label')].find(e=>(e.textContent||'').replace(/\\s+/g,'').includes(t));if(el){el.click();return true}return false})()`);
|
||||
const clickSel = (sel) => evalJs(`(()=>{const el=document.querySelector(${JSON.stringify(sel)});if(el){el.click();return true}return false})()`);
|
||||
const fill = (ph, v) => evalJs(`(()=>{const el=[...document.querySelectorAll('input,textarea')].find(e=>(e.placeholder||'').includes(${JSON.stringify(ph)}));if(el){el.value=${JSON.stringify(v)};el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));return true}return false})()`);
|
||||
const info = () => evalJs(`JSON.stringify({path:location.pathname, h1:document.querySelector('h1')?.textContent?.trim().slice(0,24), authed:!!localStorage.getItem('dm_token'), nav:[...document.querySelectorAll('header a,header button')].map(e=>e.textContent.trim()).filter(Boolean).slice(0,8)})`);
|
||||
const shot = async (name) => { const { data } = await send('Page.captureScreenshot', { format: 'png' }); writeFileSync(`${OUT}/${name}.png`, Buffer.from(data, 'base64')); };
|
||||
const step = async (label, fn) => { curStep = label; const before = errs.length; await fn(); const i = JSON.parse(await info() || '{}'); report.push({ label, ...i, newErrs: errs.length - before }); };
|
||||
|
||||
// ── 走查 ──
|
||||
await step('01-进站落广场', async () => { await nav('/'); await shot('01-plaza'); });
|
||||
await step('02-点大师卡', async () => { await clickSel('a[href^="/masters/"]'); await wait(2200); await shot('02-master'); });
|
||||
await step('03-和大师聊(点)', async () => { await clickSel('a[href^="/me/"]'); await wait(2500); await shot('03-chat-open'); });
|
||||
await step('04-输入框可用(本地无key不真发)', async () => {
|
||||
await fill('说点什么', '你好,介绍下你自己');
|
||||
await wait(600); await shot('04-chat-typed');
|
||||
});
|
||||
await step('05-返回并进大师文旅', async () => { await nav('/tours'); await shot('05-tours'); });
|
||||
await step('06-看路线详情', async () => { await clickSel('a[href^="/tours/"]'); await wait(2400); await shot('06-tour-detail'); });
|
||||
await step('07-填报名交定金', async () => {
|
||||
await fill('您的称呼', '走查客');
|
||||
await fill('手机号', '13800008888');
|
||||
await clickText('报名·交预定金锁位');
|
||||
await wait(400);
|
||||
await clickText('报名并支付预定金');
|
||||
await wait(2800); await shot('07-booked');
|
||||
});
|
||||
await step('08-去登录页', async () => { await nav('/login'); await shot('08-login'); });
|
||||
await step('09-注册新号', async () => {
|
||||
await clickText('去注册'); await wait(600);
|
||||
await fill('you@example.com', uniqEmail);
|
||||
await fill('至少 6 位', 'walk123456');
|
||||
await evalJs(`(()=>{const el=[...document.querySelectorAll('input')].find(e=>/昵称|名字|称呼|name/i.test(e.placeholder||''));if(el){el.value='走查用户';el.dispatchEvent(new Event('input',{bubbles:true}))}})()`);
|
||||
await clickText('注册'); await wait(3000); await shot('09-after-register');
|
||||
});
|
||||
await step('10-养分身/工作台', async () => { await clickText('养我的分身'); await wait(2600); await shot('10-dashboard'); });
|
||||
await step('11-我的分身导航', async () => { await clickText('我的分身'); await wait(2200); await shot('11-mine'); });
|
||||
await step('12-登出', async () => { await clickText('退出'); await wait(2200); await shot('12-logout'); });
|
||||
|
||||
writeFileSync(`${OUT}/report.json`, JSON.stringify({ report, errs }, null, 2));
|
||||
console.log('=== 走查步骤 ===');
|
||||
for (const r of report) console.log(`${r.label} → path:${r.path} authed:${r.authed} h1:"${r.h1 || ''}" 本步报错:${r.newErrs}`);
|
||||
console.log('\n=== 报错/失败请求汇总 ===');
|
||||
if (!errs.length) console.log('(无)');
|
||||
for (const e of errs) console.log(`[${e.step}] ${e.kind}: ${e.detail}`);
|
||||
sock.close(); process.exit(0);
|
||||
}
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,124 @@
|
||||
// 存量媒体迁移 → MinIO(老板 2026-08-05:文件存储改用 MinIO)。
|
||||
//
|
||||
// 把库里指向【本地 /uploads】(可选:也含【阿里云 OSS】)的媒体 URL,下载字节 → 传到 MinIO → 回写库里的 URL。
|
||||
// 深度遍历每条文档的所有字符串字段(自动覆盖 media/cover/videoUrl/bundle.* 等嵌套,无需逐字段列举)。
|
||||
//
|
||||
// 前提:
|
||||
// ① 在【服务器后端目录】跑(本地 /uploads 文件在这台机上才读得到)。
|
||||
// ② .env 已配好 MINIO_*(minioConfigured 必须为真,否则拒绝运行)。
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/migrate-media-to-minio.mjs # 【干跑】只统计不写库(强烈建议先跑这个)
|
||||
// node scripts/migrate-media-to-minio.mjs --commit # 真迁移(下载→传 MinIO→回写库 URL)
|
||||
// node scripts/migrate-media-to-minio.mjs --commit --include-oss # 连 OSS 上的也迁到 MinIO
|
||||
// node scripts/migrate-media-to-minio.mjs --commit creations create_jobs # 只迁指定集合
|
||||
//
|
||||
// 幂等:MinIO key = 原文件名(basename,已带时间戳/随机段);重复跑同一文件覆盖同 key,库里 URL 已是 MinIO 的会跳过。
|
||||
// 说明:只迁【http(s) 公开媒体 URL】;母带(存的是 {key,driver} 对象,非 URL)不在此列,如需迁移母带另说。
|
||||
import 'dotenv/config';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { connectDb, getDb, closeDb } from '../src/db.js';
|
||||
import { config } from '../src/config.js';
|
||||
import { saveMedia, minioConfigured } from '../src/services/storage.js';
|
||||
|
||||
const COMMIT = process.argv.includes('--commit');
|
||||
const INCLUDE_OSS = process.argv.includes('--include-oss');
|
||||
const COLL_ARGS = process.argv.slice(2).filter((a) => !a.startsWith('--'));
|
||||
// 默认扫这些含媒体 URL 的集合(可用命令行参数覆盖)。
|
||||
const DEFAULT_COLLS = ['creations', 'create_jobs', 'users', 'twins', 'messages', 'memories', 'masters', 'social_messages', 'shares', 'knowledge'];
|
||||
const COLLS = COLL_ARGS.length ? COLL_ARGS : DEFAULT_COLLS;
|
||||
|
||||
const localPrefix = `${config.publicUrl}/uploads/`;
|
||||
const localDir = config.storage.localDir;
|
||||
const minioBase = config.storage.minio.publicBase;
|
||||
const minioBucket = config.storage.minio.bucket;
|
||||
const ossBase = config.storage.oss.publicBase;
|
||||
|
||||
function isLocal(u) { return u.startsWith(localPrefix) || u.startsWith('/uploads/'); }
|
||||
function isMinio(u) { return (minioBase && u.startsWith(minioBase)) || (minioBucket && u.includes(`/${minioBucket}/`)); }
|
||||
function isOss(u) { return /\.aliyuncs\.com\//.test(u) || (ossBase && u.startsWith(ossBase)); }
|
||||
// 只处理我们自己托管的公开媒体 URL;第三方外链、data: 一律不动。
|
||||
function migratable(u) {
|
||||
if (!/^(https?:)?\/\//.test(u) && !u.startsWith('/uploads/')) return false;
|
||||
if (isMinio(u)) return false; // 已在 MinIO
|
||||
return isLocal(u) || (INCLUDE_OSS && isOss(u));
|
||||
}
|
||||
|
||||
async function bytesFor(u) {
|
||||
if (isLocal(u)) {
|
||||
const base = path.basename(u.split('?')[0]);
|
||||
return fs.readFile(path.join(localDir, base)); // 本地盘直接读
|
||||
}
|
||||
const r = await fetch(u); // OSS/远程:HTTP 拉
|
||||
if (!r.ok) throw new Error(`fetch ${r.status}`);
|
||||
return Buffer.from(await r.arrayBuffer());
|
||||
}
|
||||
|
||||
const cache = new Map(); // 旧 URL → 新 URL(同一文件只迁一次)
|
||||
let migrated = 0, failed = 0;
|
||||
async function migrateUrl(u) {
|
||||
if (cache.has(u)) return cache.get(u);
|
||||
try {
|
||||
const buf = await bytesFor(u);
|
||||
const base = path.basename(u.split('?')[0]) || 'file';
|
||||
const newUrl = COMMIT ? await saveMedia(buf, base) : `MINIO:${base}`; // 干跑不真传
|
||||
cache.set(u, newUrl);
|
||||
migrated++;
|
||||
if (migrated % 20 === 0) console.log(` …已处理 ${migrated} 个文件`);
|
||||
return newUrl;
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.warn(` ✗ 跳过 ${u} —— ${e.message}`);
|
||||
cache.set(u, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// BSON 特殊类型(ObjectId/Date/Binary 等)不递归。
|
||||
function isPlainContainer(o) {
|
||||
return o && typeof o === 'object' && !o._bsontype && !(o instanceof Date) && !Buffer.isBuffer(o);
|
||||
}
|
||||
async function walk(node) {
|
||||
let changed = false;
|
||||
const entries = Array.isArray(node) ? node.map((v, i) => [i, v]) : Object.entries(node);
|
||||
for (const [k, v] of entries) {
|
||||
if (typeof v === 'string') {
|
||||
if (migratable(v)) { const nu = await migrateUrl(v); if (nu) { node[k] = nu; changed = true; } }
|
||||
} else if (isPlainContainer(v)) {
|
||||
if (await walk(v)) changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!minioConfigured) {
|
||||
console.error('❌ 未配置 MinIO(MINIO_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY)。先在 .env 配好再跑。');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`模式: ${COMMIT ? '★ COMMIT(真写库)' : '干跑(只统计,不改库/不传文件)'} | 含OSS: ${INCLUDE_OSS ? '是' : '否'} | 集合: ${COLLS.join(', ')}`);
|
||||
await connectDb();
|
||||
const db = getDb();
|
||||
let totalChanged = 0;
|
||||
for (const coll of COLLS) {
|
||||
let scanned = 0, changedDocs = 0;
|
||||
try {
|
||||
for await (const doc of db.collection(coll).find({})) {
|
||||
scanned++;
|
||||
if (await walk(doc)) {
|
||||
changedDocs++;
|
||||
if (COMMIT) await db.collection(coll).replaceOne({ _id: doc._id }, doc);
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn(`[${coll}] 扫描出错(可能无此集合): ${e.message}`); continue; }
|
||||
totalChanged += changedDocs;
|
||||
console.log(`[${coll}] 扫描 ${scanned} 文档,需迁移 ${changedDocs} 条`);
|
||||
}
|
||||
console.log('──────────────────────────');
|
||||
console.log(`唯一文件: ${cache.size} 迁移成功: ${migrated} 失败: ${failed} 受影响文档: ${totalChanged}`);
|
||||
console.log(COMMIT ? '✅ 已写库。' : 'ℹ️ 干跑结束——加 --commit 才会真迁移。');
|
||||
await closeDb();
|
||||
process.exit(0);
|
||||
}
|
||||
run().catch((e) => { console.error('迁移失败:', e); process.exit(1); });
|
||||
@@ -0,0 +1,95 @@
|
||||
// ★★★ gapOf 的 A/B —— 【它裁决几次?它改变几格?】★ 这是两问,不是一问。
|
||||
// (先例:南朝的 lex 被【读了 98,304 次、裁决了 81,920 次】,而它【改变 0 格】。
|
||||
// ⇒ 一个被读了八万次的东西,可以什么都没买到。)
|
||||
//
|
||||
// ⚠ 本脚本【自己造两份引擎的副本,跑完自己删】—— 不在 src/ 里留残骸。
|
||||
// ★ 副本是【整个文件复制 + 改一行】,不是重写一遍逻辑:
|
||||
// 重写 = 复算 = 拿另一把尺子量,而那比不报更坏(本项目铁律)。
|
||||
//
|
||||
// A/B ① 把 nearest 排序里的 `gapOf(a) - gapOf(b) ||` 那一级【整个拿掉】—— 其余一个字未动。
|
||||
// A/B ② 把 20 道六轴闸【全部改写成它等价类的下端 41】—— ★ 逐格【同一道闸】
|
||||
// (六轴只取得到 40/63/73/79/83 ⇒ (40,63) 之间没有任何 profile)
|
||||
// ⇒ 开/关一格都不会变,【只有 gapOf 读得出差别】。
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import url from 'node:url';
|
||||
|
||||
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const SRC = path.join(HERE, '..', 'src', 'dynasties.js');
|
||||
const NOGAP = path.join(HERE, '..', 'src', '_probe_nogap.js');
|
||||
const GATE41 = path.join(HERE, '..', 'src', '_probe_gate41.js');
|
||||
const NEEDLE = ' gapOf(a) - gapOf(b) ||';
|
||||
|
||||
const base = fs.readFileSync(SRC, 'utf8');
|
||||
if (!base.includes(NEEDLE)) {
|
||||
console.error('✗ 在 dynasties.js 里找不到 gapOf 那一级 —— 引擎变了,这个探针过期了。');
|
||||
console.error(' ⚠ 而【不许改探针去迁就代码】:先弄清楚那一级去哪了。');
|
||||
process.exit(1);
|
||||
}
|
||||
fs.writeFileSync(NOGAP, base.replace(NEEDLE, ' // [PROBE] gapOf 那一级拿掉了'));
|
||||
// ★ 只改【六轴】的闸;wang/lao 不是 sat 的产物,它们的数是【真的刻度】,一个字都不许动。
|
||||
fs.writeFileSync(GATE41, base.replace(/gate: \{([^}]*)\}/g, (m, g) =>
|
||||
'gate: {' + g.replace(/\b(wen|fa|wu|jiao|heng|tan):\s*(4[1-9]|5\d|6[0-3])\b/g, '$1: 41') + '}'));
|
||||
|
||||
try {
|
||||
const { DYNASTIES, computeRank, computeAxes, AXIS_IDS } = await import('../src/dynasties.js');
|
||||
const { computeRank: rankNoGap } = await import('../src/_probe_nogap.js');
|
||||
const { computeRank: rankGate41 } = await import('../src/_probe_gate41.js');
|
||||
const { chushenOf } = await import('../src/chushen.js');
|
||||
const { SUB_REGISTRY } = await import('../src/talents.js');
|
||||
|
||||
const SUBS = SUB_REGISTRY.map((s) => s.id);
|
||||
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100];
|
||||
const m = new Map();
|
||||
for (let b = 0; b < (1 << 16); b++) {
|
||||
const s = []; for (let i = 0; i < 16; i++) if (b & (1 << i)) s.push(SUBS[i]);
|
||||
const a = computeAxes(s); const k = AXIS_IDS.map((x) => a[x]).join(',');
|
||||
m.set(k, (m.get(k) || 0) + 1);
|
||||
}
|
||||
const T = [...m].map(([k, w]) => ({ axes: Object.fromEntries(k.split(',').map((v, i) => [AXIS_IDS[i], Number(v)])), w }));
|
||||
const CFG = {};
|
||||
for (const d of DYNASTIES) { const x = chushenOf(d.id); CFG[d.id] = x ? [null, ...x.values.map((v) => v.id)] : [null]; }
|
||||
const N = (n) => n.toLocaleString();
|
||||
const pad = (s, n) => { s = String(s); let w = 0; for (const c of s) w += c.charCodeAt(0) > 0x2000 ? 2 : 1; return s + ' '.repeat(Math.max(1, n - w)); };
|
||||
|
||||
console.log('\n══ A/B ① 把 gapOf 那一级【整个拿掉】:nearest 变几格?(其余一个字未动)');
|
||||
console.log('══ A/B ② 20 道六轴闸【全换成等价类下端 41】:★ 逐格【同一道闸】,只有 gapOf 读得出差别\n');
|
||||
console.log(pad('朝', 8) + pad('出身', 10) + '无路者'.padStart(11) + '① 拿掉 gapOf'.padStart(15) + '② 闸全写 41'.padStart(14));
|
||||
console.log('─'.repeat(60));
|
||||
let G1 = 0, G2 = 0;
|
||||
for (const d of DYNASTIES) for (const cs of CFG[d.id]) {
|
||||
let noRoad = 0, d1 = 0, d2 = 0;
|
||||
for (const t of T) for (const wang of WANGS) {
|
||||
const p = { axes: t.axes, wang, chushen: cs };
|
||||
const a = computeRank(p, d.id);
|
||||
if (a.channel) continue;
|
||||
noRoad += t.w;
|
||||
const b = rankNoGap(p, d.id), c = rankGate41(p, d.id);
|
||||
// ★ ② 的前提:闸没变 ⇒「一条路都不开」这件事必须逐格不变。变了就说明我改错了闸。
|
||||
if (c.channel) throw new Error(`★ ② 改写了开/关 —— 那它就不是同一道闸,本探针的前提塌了(${d.id})`);
|
||||
if ((a.nearest || '-') !== (b.nearest || '-')) d1 += t.w;
|
||||
if ((a.nearest || '-') !== (c.nearest || '-')) d2 += t.w;
|
||||
}
|
||||
if (!noRoad) continue;
|
||||
G1 += d1; G2 += d2;
|
||||
console.log(pad(d.name, 8) + pad(cs || '未答', 10) + N(noRoad).padStart(11) + N(d1).padStart(15) + N(d2).padStart(14));
|
||||
}
|
||||
console.log('─'.repeat(60));
|
||||
console.log(pad('总计', 18) + ''.padStart(11) + N(G1).padStart(15) + N(G2).padStart(14));
|
||||
console.log(`\n★ ①【拿掉 gapOf ⇒ ${N(G1)} 格换一条路当 nearest】—— 这就是「改判决」的代价(要老板拍)。`);
|
||||
console.log('★★ 而【拿掉它不会让答案变对】:它只是把【一个假装是测量的仲裁】换成【一个自认是编号的仲裁】。');
|
||||
console.log(`★ ②【20 道闸全写成 41(逐格同一道闸)⇒ ${N(G2)} 格变】—— ★ 注意它【比 ① 小得多】:`);
|
||||
console.log(' 全表【一起】换写法,缺口一起缩,相对次序大多不动;');
|
||||
console.log(' ⇒ ★★【真正翻盘的是【单独】换一道闸的写法】—— 见 scripts/probe-liguang-gate.mjs:');
|
||||
console.log(' 军功的 wu:55 写成 wu:41(同一道闸)⇒ 李广的 nearest 从【辟召】变成【军功】。');
|
||||
|
||||
// ★ 李广:renwu.js 双证 subs=[] ⇒ 六轴全 40。《史記》卷109 双证:他的实职就是靠军功挣的。
|
||||
const li = { axes: computeAxes([]), wang: 0 };
|
||||
for (const [nm, fn] of [['真引擎', computeRank], ['① 拿掉 gapOf', rankNoGap], ['② 闸全写 41', rankGate41]]) {
|
||||
const r = fn(li, 'han');
|
||||
console.log(`\n★ 李广(subs=[] ⇒ 六轴全 40)/汉 ── ${pad(nm, 14)} nearest =【${r.nearest}】 tie = [${(r.nearestTie || []).join('、')}]`);
|
||||
}
|
||||
} finally {
|
||||
// ⚠ 跑完就删:✗ 不许在 src/ 里留下两份引擎的副本 —— 那是下一个人会 import 错的东西。
|
||||
for (const f of [NOGAP, GATE41]) if (fs.existsSync(f)) fs.unlinkSync(f);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// ★★★★★ 【李广 —— 而这一次不是编号,是本表在 41–63 里挑了哪一个数】
|
||||
import { DYNASTIES, computeRank, computeAxes } from '../src/dynasties.js';
|
||||
const han = DYNASTIES.find((d) => d.id === 'han');
|
||||
const jg = han.channels.find((c) => c.id === 'jungong_han');
|
||||
const li = { axes: computeAxes([]), wang: 0 }; // ★ 李广:renwu.js 双证 subs=[] ⇒ 六轴全 40
|
||||
console.log('★ 李广 六轴:', JSON.stringify(li.axes), ' wang=0\n');
|
||||
console.log('汉三条闸:', han.channels.map((c) => `${c.name}=${JSON.stringify(c.gate)}`).join(' '), '\n');
|
||||
console.log('【把军功的闸 wu:55 换成它等价类 41–63 里的每一个数】');
|
||||
console.log(' ⚠ 每一个都是【同一道闸】:六轴取值只有 40/63/73/79/83,(40,63] 之间没有任何 profile。\n');
|
||||
console.log(' wu 闸 李广被判「最接近」的那条路 打平的 他的军功缺口');
|
||||
console.log(' ' + '─'.repeat(78));
|
||||
const seen = new Map();
|
||||
for (const n of [41, 45, 50, 54, 55, 56, 60, 63]) {
|
||||
jg.gate.wu = n;
|
||||
const r = computeRank(li, 'han');
|
||||
const gap = n - 40;
|
||||
console.log(` ${String(n).padEnd(8)} ${(r.nearest || '-').padEnd(34)} [${(r.nearestTie || []).join('、').padEnd(18)}] 差 ${gap}`);
|
||||
seen.set(r.nearest, (seen.get(r.nearest) || 0) + 1);
|
||||
}
|
||||
jg.gate.wu = 55;
|
||||
console.log('\n★★★ 同一道闸,8 个写法 →', [...seen].map(([k, v]) => `${k}: ${v} 次`).join(' '));
|
||||
console.log('★★★★【「军功不是最接近的那条」不是一个关于李广的事实,是【本表在 41–63 里挑了 55】这个事实。】');
|
||||
console.log(' ⇒ 而《史記》卷109 双证钉着:他的实职【就是靠军功挣的】。');
|
||||
@@ -0,0 +1,34 @@
|
||||
// ★ 李广/李蔡 落在哪一支 + 打平集合的势
|
||||
import { computeRank, profileFromSubs, computeAxes, DYNASTIES } from '../src/dynasties.js';
|
||||
import { subsOf, renwuOf } from '../src/renwu.js';
|
||||
import { SUB_REGISTRY } from '../src/talents.js';
|
||||
|
||||
for (const id of ['liguang', 'licai']) {
|
||||
const subs = subsOf(id);
|
||||
const r = computeRank(profileFromSubs(subs, { wang: 0 }), 'han');
|
||||
console.log(`\n【${renwuOf(id).name}】subs=${JSON.stringify(subs)} axes=${JSON.stringify(computeAxes(subs))}`);
|
||||
console.log(` channel=${r.channel ? r.channel.id : 'null'} tier=${r.rank.tier} title=${r.rank.title}`);
|
||||
console.log(` nearest=${r.nearest} nearestTie=${JSON.stringify(r.nearestTie)}`);
|
||||
console.log(` kai=${JSON.stringify(r.kai.map((k) => k.channel))} zelu.options=${JSON.stringify(r.zelu.options)}`);
|
||||
}
|
||||
|
||||
const SUBS = SUB_REGISTRY.map((s) => s.id);
|
||||
let opt3 = 0, boss3 = 0, optN = 0, bossN = 0;
|
||||
for (let m = 0; m < (1 << 16); m++) {
|
||||
const subs = []; for (let i = 0; i < 16; i++) if (m & (1 << i)) subs.push(SUBS[i]);
|
||||
const axes = computeAxes(subs);
|
||||
for (const wang of [0, 29, 30, 54, 55, 74, 75, 100]) for (const d of DYNASTIES) {
|
||||
const r = computeRank({ axes, wang }, d.id);
|
||||
if (!r.channel) continue;
|
||||
const walked = r.kai.find((k) => k.walked);
|
||||
const bossTie = r.kai.filter((k) => k.tier === walked.tier && k.ceiling === walked.ceiling);
|
||||
if (bossTie.length > 1) bossN++;
|
||||
if (bossTie.length > 2) boss3++;
|
||||
const o = (r.zelu.options || []).length;
|
||||
if (o > 1) optN++;
|
||||
if (o > 2) opt3++;
|
||||
}
|
||||
}
|
||||
const N = (n) => n.toLocaleString();
|
||||
console.log(`\n★ 现行 zelu.options(只收 lostAt==='idx'):>1 条 ${N(optN)} 格;≥3 条 ${N(opt3)} 格`);
|
||||
console.log(`★ 老板判据(同 tier 同 ceiling): >1 条 ${N(bossN)} 格;≥3 条 ${N(boss3)} 格`);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { computeRank, profileFromSubs, DYNASTIES, computeAxes } from '../src/dynasties.js';
|
||||
import { SUB_REGISTRY } from '../src/talents.js';
|
||||
const SUBS = SUB_REGISTRY.map((s) => s.id);
|
||||
const r = computeRank(profileFromSubs(['rigorous','datadriven','concise','shortfirst'], { wang: 0 }), 'weijin');
|
||||
console.log('魏晋 clerk: nearest=', r.nearest, ' tie=', JSON.stringify(r.nearestTie), ' isDefault=', r.nearestIsDefault);
|
||||
// 无路者里,nearest【没有】打平的格子占多少?找一个例子
|
||||
let hasTie = 0, noTie = 0, ex = null;
|
||||
for (let m = 0; m < (1 << 16); m++) {
|
||||
const subs = []; for (let i = 0; i < 16; i++) if (m & (1 << i)) subs.push(SUBS[i]);
|
||||
const axes = computeAxes(subs);
|
||||
for (const wang of [0, 29, 30, 54, 55, 74, 75, 100]) for (const d of DYNASTIES) {
|
||||
const r = computeRank({ axes, wang }, d.id);
|
||||
if (r.channel || !r.nearest) continue;
|
||||
if (r.nearestTie.length) hasTie++;
|
||||
else { noTie++; if (!ex) ex = { d: d.id, subs, wang, nearest: r.nearest }; }
|
||||
}
|
||||
}
|
||||
console.log(`\n无路者·nearest 打平的格子 ${hasTie.toLocaleString()};【没打平】的 ${noTie.toLocaleString()}`);
|
||||
console.log('没打平的一个例子:', JSON.stringify(ex));
|
||||
@@ -0,0 +1,32 @@
|
||||
// ★★★ 自查:【这一刀新加的每一个字段/每一个分支,本身是不是又一个空操作?】
|
||||
// ⚠ 本文件的铁律逐字:「不制造第二个」。而第八刀在「让它出声」那一刀里【自己造了一个】(byZelu)。
|
||||
// ⇒ 所以这一刀的新字段必须自己先过一遍 census:【每一个取值都要真的跑到】。
|
||||
import { DYNASTIES, computeRank, computeAxes, AXIS_IDS } from '../src/dynasties.js';
|
||||
import { chushenOf } from '../src/chushen.js';
|
||||
import { SUB_REGISTRY } from '../src/talents.js';
|
||||
const SUBS = SUB_REGISTRY.map((s) => s.id);
|
||||
const WANGS = [0, 29, 30, 54, 55, 74, 75, 100];
|
||||
const m = new Map();
|
||||
for (let b = 0; b < (1 << 16); b++) { const s = []; for (let i = 0; i < 16; i++) if (b & (1 << i)) s.push(SUBS[i]);
|
||||
const a = computeAxes(s); const k = AXIS_IDS.map((x) => a[x]).join(','); m.set(k, (m.get(k) || 0) + 1); }
|
||||
const T = [...m].map(([k, w]) => ({ axes: Object.fromEntries(k.split(',').map((v, i) => [AXIS_IDS[i], Number(v)])), w }));
|
||||
const CFG = {}; for (const d of DYNASTIES) { const x = chushenOf(d.id); CFG[d.id] = x ? [null, ...x.values.map((v) => v.id)] : [null]; }
|
||||
const N = (n) => n.toLocaleString();
|
||||
const hit = { by_count: 0, by_gap: 0, by_idx: 0, sameAxis_true: 0, sameAxis_false: 0, mixed_true: 0, mixed_false: 0, kind_scale: 0, kind_codename: 0, rivals1: 0, rivals2plus: 0, gapLine: 0 };
|
||||
const byDyn = {};
|
||||
for (const d of DYNASTIES) for (const cs of CFG[d.id]) for (const t of T) for (const wang of WANGS) {
|
||||
const r = computeRank({ axes: t.axes, wang, chushen: cs }, d.id);
|
||||
if (r.channel || !r.nearest) continue;
|
||||
hit['by_' + r.nearestBy] += t.w;
|
||||
if (r.nearestBy !== 'gap') continue;
|
||||
const g = r.nearestGap;
|
||||
hit[g.sameAxis ? 'sameAxis_true' : 'sameAxis_false'] += t.w;
|
||||
hit[g.mixedKind ? 'mixed_true' : 'mixed_false'] += t.w;
|
||||
for (const s of [g.mine, ...g.rivals]) hit['kind_' + s.kind] += t.w;
|
||||
hit[g.rivals.length === 1 ? 'rivals1' : 'rivals2plus'] += t.w;
|
||||
if (r.reasons.some((x) => /最接近的那条是怎么挑出来的/.test(x))) hit.gapLine += t.w;
|
||||
if (g.sameAxis) { const k = `${d.id}|${cs}`; byDyn[k] = (byDyn[k] || 0) + t.w; }
|
||||
}
|
||||
console.log('\n══ 这一刀新加的每一个取值,跑到了几格?【0 = 我造了一个新的空操作】══\n');
|
||||
for (const [k, v] of Object.entries(hit)) console.log(` ${k.padEnd(18)} ${N(v).padStart(12)} ${v === 0 ? ' ★★★★ 死!这是我自己造的空操作' : ''}`);
|
||||
console.log('\n sameAxis=true 落在:', Object.keys(byDyn).length ? JSON.stringify(byDyn) : '【没有一格】');
|
||||
@@ -0,0 +1,54 @@
|
||||
// ★ 择路排序器【自洽吗】—— TIE_TOL=5 是【容差】,而容差不传递。
|
||||
// 一个不传递的比较器交给 Array.sort,结果是【实现定义】的:ranked[0] 可以不是极大元。
|
||||
// 本表逐字写过:「『碰巧成立』和『守着』是两回事」。⇒ 跑出来。
|
||||
import { DYNASTIES, computeAxes, getDynasty, AXES } from '../src/dynasties.js';
|
||||
import { SUB_REGISTRY } from '../src/talents.js';
|
||||
const SUBS = SUB_REGISTRY.map((s) => s.id);
|
||||
const TIE_TOL = 5;
|
||||
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
||||
const xuanOf = (c) => (c && c.xuan) || { seat: 0, kemu: [], key: [] };
|
||||
function lexCompare(a, b, val) {
|
||||
const ka = xuanOf(a).key || [], kb = xuanOf(b).key || [];
|
||||
const n = Math.min(ka.length, kb.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const va = val(ka[i]), vb = val(kb[i]);
|
||||
if (Math.abs(va - vb) > TIE_TOL) return vb - va;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
let lexNonTrans = 0, cells = 0, tieNonEquiv = 0, tieNonEquivCells = 0;
|
||||
for (let m = 0; m < (1 << 16); m++) {
|
||||
const subs = []; for (let i = 0; i < 16; i++) if (m & (1 << i)) subs.push(SUBS[i]);
|
||||
const axes = computeAxes(subs);
|
||||
for (const wang of [0, 29, 30, 54, 55, 74, 75, 100]) for (const d of DYNASTIES) {
|
||||
const val = (k) => (k === 'wang' ? wang : k === 'lao' ? 0 : clamp(Number(axes[k] || 0), 0, 100));
|
||||
const open = d.channels.filter((c) => Object.entries(c.gate || {}).every(([k, need]) => val(k) >= need));
|
||||
if (open.length < 3) continue;
|
||||
cells++;
|
||||
const ceilOf = (c) => c.ceiling?.tier ?? 5;
|
||||
const tierOf = (c) => {
|
||||
const kemu = xuanOf(c).kemu || [];
|
||||
let n = 0; for (const k of kemu) { if (val(k.axis) >= k.need) n++; else break; }
|
||||
return clamp((xuanOf(c).seat || 0) + n, 0, ceilOf(c));
|
||||
};
|
||||
// ①【lexCompare 的「均」传递吗】A≈B、B≈C ⇒ A≈C ?
|
||||
for (const A of open) for (const B of open) for (const C of open) {
|
||||
if (A === B || B === C || A === C) continue;
|
||||
if (lexCompare(A, B, val) === 0 && lexCompare(B, C, val) === 0 && lexCompare(A, C, val) !== 0) lexNonTrans++;
|
||||
}
|
||||
// ②【老板判据的打平集合,内部两两真的都同 tier 同 ceiling 吗】(= 是等价关系吗)
|
||||
const best = open.slice().sort((a, b) =>
|
||||
tierOf(b) - tierOf(a) || ceilOf(b) - ceilOf(a) || lexCompare(a, b, val) || 0)[0];
|
||||
const tie = open.filter((c) => tierOf(c) === tierOf(best) && ceilOf(c) === ceilOf(best));
|
||||
let bad = 0;
|
||||
for (const A of tie) for (const B of tie)
|
||||
if (A !== B && (tierOf(A) !== tierOf(B) || ceilOf(A) !== ceilOf(B))) bad++;
|
||||
tieNonEquiv += bad; if (bad) tieNonEquivCells++;
|
||||
}
|
||||
}
|
||||
const N = (n) => n.toLocaleString();
|
||||
console.log(`\n扫了 ${N(cells)} 个【≥3 条路开着】的格子`);
|
||||
console.log(`①【lexCompare 的「均」不传递】的三元组:${N(lexNonTrans)} 次`);
|
||||
console.log(` ⇒ 容差关系 |a−b|≤5 【不是等价关系】。而 idx 的成员资格【是拿它定义的】。`);
|
||||
console.log(`②【老板判据(同 tier 同 ceiling)】内部不自洽:${N(tieNonEquiv)} 次(${N(tieNonEquivCells)} 格)`);
|
||||
console.log(` ⇒ 相等【是】等价关系。老板的判据是良定义的,我上一刀那个不是。`);
|
||||
@@ -0,0 +1,33 @@
|
||||
// CosyVoice 可用性探针:拿本机 .env 的 DASHSCOPE key 打真实语音合成,报「能用/不能用+确切错误」。
|
||||
// 用法(服务器后端目录内,.env 已配 DASHSCOPE_API_KEY): node scripts/probe-tts.mjs
|
||||
//
|
||||
// 结果解读:
|
||||
// ✅ 出音(音频字节>0) → 该 (model, voice) 可用。
|
||||
// ❌ ModelNotFound / 未开通 → 百炼账号未开通该 CosyVoice 版本(账号层,去百炼控制台开通)。
|
||||
// ❌ voice 不匹配/无此音色 → 该版本的预置音色名不对(v2/v3/v3.5 音色不通用)。
|
||||
import 'dotenv/config';
|
||||
import { config } from '../src/config.js';
|
||||
import { synthesizeWs } from '../src/services/ttsWs.js';
|
||||
|
||||
// 探测组合:预置音(v2 最可能已开) + 你当前克隆音默认版本(用 v2 音色仅为触发,看是"模型未开"还是"音色不匹配")。
|
||||
const CASES = [
|
||||
{ label: '预置音 v2', model: 'cosyvoice-v2', voice: 'longwan_v2' },
|
||||
{ label: `克隆默认(${config.tts.cloneModel})`, model: config.tts.cloneModel, voice: 'longwan_v2' },
|
||||
];
|
||||
|
||||
console.log('DASHSCOPE key:', config.llm.dashscopeKey ? '已配(尾4:' + config.llm.dashscopeKey.slice(-4) + ')' : '❌未配');
|
||||
console.log('WS 端点:', config.tts.wsUrl);
|
||||
console.log('config.tts:', JSON.stringify(config.tts));
|
||||
console.log('──────── 探测 ────────');
|
||||
|
||||
for (const c of CASES) {
|
||||
process.stdout.write(`· ${c.label} [${c.model} / ${c.voice}] … `);
|
||||
try {
|
||||
const r = await synthesizeWs('你好,这是一次语音合成测试。', { voiceId: c.voice, model: c.model });
|
||||
const bytes = r?.audio ? Buffer.from(r.audio, 'base64').length : 0;
|
||||
console.log(bytes > 0 ? `✅ 出音 ${bytes} 字节` : '❌ 无音频');
|
||||
} catch (e) {
|
||||
console.log('❌', String(e?.message || e));
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,64 @@
|
||||
// 把 MongoDB knowledge 集合里已有的向量重建进 DashVector 索引。
|
||||
//
|
||||
// 何时用:①首次接入 DashVector(建集合 + 灌存量向量);②补齐入库时同步失败的块(标了 vectorPending 的);
|
||||
// ③换过 embedding 维度后重灌(见下"换维度")。
|
||||
//
|
||||
// 幂等性:本脚本用 insert 语义,只【补齐缺失】的向量、灌成功后清 vectorPending 标记。它【不覆盖】已存在
|
||||
// 同 _id 的向量(在线入库每块都是全新 _id,不会撞;故正常场景无需覆盖)。
|
||||
// ⚠ 换维度 / 修复"已存在但内容错"的向量:insert 不覆盖,必须【新建集合】——把 .env 的 DASHVECTOR_COLLECTION
|
||||
// 改成新名(或在阿里云控制台删掉旧集合)后再跑本脚本。ensureCollection 已会在维度不符时【大声报错】提示你。
|
||||
//
|
||||
// 用法(服务器后端目录内,.env 里已配好 DASHVECTOR_*): node scripts/reindex-vectors.js
|
||||
// MongoDB 永远是真相源,本脚本只把真相源投影进向量索引;不改动 knowledge 任何数据(仅清 vectorPending 标记)。
|
||||
import 'dotenv/config';
|
||||
import { connectDb, getDb, closeDb } from '../src/db.js';
|
||||
import { config } from '../src/config.js';
|
||||
import { vectorEnabled, ensureCollection, vectorUpsert } from '../src/services/vectorStore.js';
|
||||
|
||||
async function run() {
|
||||
if (!vectorEnabled()) {
|
||||
console.error('❌ 向量库未启用。请在 .env 配 DASHVECTOR_ENDPOINT / DASHVECTOR_API_KEY(和可选 DASHVECTOR_COLLECTION),再重跑。');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`向量库: DashVector · collection=${config.vector.dashvector.collection} · dim=${config.vector.dashvector.dim}`);
|
||||
|
||||
console.log('① 确保集合存在且就绪(维度不符会在此报错)…');
|
||||
const c = await ensureCollection();
|
||||
console.log(c.existed ? ' 集合已存在,复用。' : c.notReady ? ' 集合已创建(尚未就绪,后续写入会自动重试)。' : ' 集合已创建、就绪。');
|
||||
|
||||
await connectDb();
|
||||
const db = getDb();
|
||||
const cursor = db.collection('knowledge')
|
||||
.find({ embedding: { $type: 'array' } })
|
||||
.project({ embedding: 1, twinId: 1, sourceType: 1 })
|
||||
.addCursorFlag('noCursorTimeout', true) // 大集合 + 慢/限流的 DashVector:防两次 getMore 间的阻塞 upsert 超过游标空闲超时(10min)
|
||||
.batchSize(500);
|
||||
|
||||
let batch = [];
|
||||
let ids = [];
|
||||
let total = 0;
|
||||
const BATCH = 200;
|
||||
const flush = async () => {
|
||||
if (!batch.length) return;
|
||||
await vectorUpsert(batch);
|
||||
// 灌成功 → 清掉这些块入库时可能打上的 vectorPending 标记(它们已进索引)。
|
||||
try { await db.collection('knowledge').updateMany({ _id: { $in: ids } }, { $unset: { vectorPending: '' } }); } catch { /* 清标失败无害 */ }
|
||||
total += batch.length;
|
||||
process.stdout.write(`\r 已重建 ${total} 块…`);
|
||||
batch = []; ids = [];
|
||||
};
|
||||
|
||||
console.log('② 遍历 knowledge 集合,灌入向量…');
|
||||
for await (const d of cursor) {
|
||||
if (!Array.isArray(d.embedding) || !d.embedding.length) continue;
|
||||
batch.push({ id: String(d._id), embedding: d.embedding, twinId: String(d.twinId), sourceType: d.sourceType || 'text' });
|
||||
ids.push(d._id);
|
||||
if (batch.length >= BATCH) await flush();
|
||||
}
|
||||
await flush();
|
||||
|
||||
console.log(`\n✅ 完成。共重建 ${total} 块向量进 DashVector。`);
|
||||
await closeDb();
|
||||
}
|
||||
|
||||
run().catch((e) => { console.error('\n❌ 重建失败:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,209 @@
|
||||
// 样板数据:三城大师墙 + 高端文旅路线(成都 / 苏州 / 徽州)。
|
||||
// 用法:node scripts/seed-masters.js (幂等,可反复跑)
|
||||
import 'dotenv/config';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import { connectDb, getDb, closeDb } from '../src/db.js';
|
||||
|
||||
const SEED = 'chengdu-master-wall'; // 沿用同一 seedTag,一键清旧重灌
|
||||
|
||||
// ── 大师(跨三城)────────────────────────────────────────────────
|
||||
const MASTERS = [
|
||||
// 成都
|
||||
{ slug: 'zhucheng', displayName: '朱成', honorific: '朱成', domain: 'diaosu', region: '成都', masterTier: 'flagship',
|
||||
tagline: '用石头留住一座城的记忆', masterworks: ['朱成石刻艺术博物馆·馆长', '万佛墙', '抢救性民间石刻造像收藏'], poolWeight: 2,
|
||||
characterPrompt: '我是朱成,雕塑家,朱成石刻艺术博物馆馆长。几十年来我在抢救、收藏民间石刻造像,把散落各处的民间美学与信仰留下来。我谈艺术、谈收藏、谈一座城的记忆,语气朴实、有温度。',
|
||||
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4', // 占位样片,替换为大师真实短片
|
||||
tags: ['雕塑', '石刻', '民间博物馆', '成都'] },
|
||||
{ slug: 'lijiang-art', displayName: '李江', honorific: '李江教授', domain: 'meishu', region: '成都', masterTier: 'resident',
|
||||
tagline: '笔墨见性,画如其人', masterworks: ['美术学院系主任', '当代绘画'], poolWeight: 1,
|
||||
characterPrompt: '我是李江,画家,美术学院系主任。我谈绘画语言、创作观、如何看一幅画。面对习作我会具体点评构图、笔性、色调、气韵,温和而犀利。',
|
||||
tags: ['美术', '绘画', '当代艺术', '成都'] },
|
||||
{ slug: 'tangjie', displayName: '鲁杰', honorific: '鲁杰先生', domain: 'gujian', region: '成都', masterTier: 'flagship',
|
||||
tagline: '营造之道,藏在斗拱之间', masterworks: ['中国古建泰斗', '营造法式研究'], poolWeight: 1,
|
||||
characterPrompt: '我是鲁杰,专研中国古建筑。我讲斗拱、营造法式、会馆与庙宇的形制,也讲古建的当代传承。语气沉稳、爱"论道"。',
|
||||
tags: ['古建', '营造法式', '历史建筑', '成都'] },
|
||||
// 苏州
|
||||
{ slug: 'shenyun-su', displayName: '沈云', honorific: '沈云', domain: 'feiyi', region: '苏州', masterTier: 'flagship',
|
||||
tagline: '以针代笔,一线一世界', masterworks: ['苏绣非遗代表性传承人', '双面绣'], poolWeight: 1,
|
||||
characterPrompt: '我是沈云,苏绣非遗传承人。我谈刺绣的针法、丝理、配色与"以针代笔"的意境,也讲一幅绣品背后几个月的功夫与心境,温婉细致。',
|
||||
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4', // 占位样片
|
||||
tags: ['苏绣', '非遗', '刺绣', '苏州'] },
|
||||
{ slug: 'chenyuan-su', displayName: '陈园', honorific: '陈园先生', domain: 'gujian', region: '苏州', masterTier: 'resident',
|
||||
tagline: '移步换景,咫尺山林', masterworks: ['苏州园林营造研究', '叠山理水'], poolWeight: 1,
|
||||
characterPrompt: '我是陈园,研究苏州园林营造。我讲叠山、理水、借景、移步换景,如何在方寸间造出咫尺山林,也讲文人园林的意趣。',
|
||||
tags: ['园林', '造园', '古建', '苏州'] },
|
||||
// 徽州
|
||||
{ slug: 'humo-hui', displayName: '胡墨', honorific: '胡墨', domain: 'feiyi', region: '徽州', masterTier: 'flagship',
|
||||
tagline: '一两徽墨一两金', masterworks: ['徽墨制作技艺传承人', '歙砚鉴藏'], poolWeight: 1,
|
||||
characterPrompt: '我是胡墨,做徽墨、赏歙砚。我谈点烟、和胶、捣杵、描金的制墨古法,也讲文房四宝里的门道与文人风骨。',
|
||||
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4', // 占位样片
|
||||
tags: ['徽墨', '歙砚', '文房', '非遗', '徽州'] },
|
||||
{ slug: 'fangying-hui', displayName: '方营', honorific: '方营先生', domain: 'gujian', region: '徽州', masterTier: 'resident',
|
||||
tagline: '粉墙黛瓦马头墙', masterworks: ['徽派建筑营造研究', '徽州三雕'], poolWeight: 1,
|
||||
characterPrompt: '我是方营,研究徽派建筑。我讲马头墙、天井、三雕(木雕/石雕/砖雕)与聚族而居的宗族空间,也讲徽商衣锦还乡如何营造一座村落。',
|
||||
tags: ['徽派建筑', '古村落', '三雕', '徽州'] },
|
||||
];
|
||||
|
||||
// ── 文旅路线(引用大师 slug)────────────────────────────────────
|
||||
const TOURS = [
|
||||
{
|
||||
title: '成都·大师人文之旅(5天·私宴接待级)', region: '成都', tier: 'private',
|
||||
priceHint: 50000, depositRate: 0.5, seats: 8,
|
||||
masters: [['zhucheng', 2], ['lijiang-art', 1], ['tangjie', 1]],
|
||||
highlights: ['三位泰斗级大师亲自陪同 + 私宴(市面不可复制)', '顶奢文化酒店(大慈寺旁·博舍级)+ 专属管家', '8 人极致小团 · 数字分身行前讲解 + 行后永续问道'],
|
||||
included: ['5 晚顶奢文化住宿', '全程专属管家', '三场大师私宴/茶叙', '博物馆/画室/古建深度接待', '数字传承卡'],
|
||||
itinerary: [
|
||||
{ day: 1, title: '入蓉·序章:大慈千年', desc: '专属管家接机 → 入住文化酒店 → 大慈寺·太古里人文漫步 → 欢迎晚宴;朱成馆长出席茶叙破冰。' },
|
||||
{ day: 2, title: '朱成·石头里的众生', desc: '朱成石刻艺术博物馆深度导览(万佛墙/民间造像),馆长亲自讲解 → 拓片/雕塑手作、大师点评 → 私宴。' },
|
||||
{ day: 3, title: '李江·笔墨见性', desc: '蓝顶艺术区·李江画室,看画论画、大师现场示范 → 命题写生一对一指点 → 当代艺术下午茶雅集。' },
|
||||
{ day: 4, title: '鲁杰·营造之道', desc: '洛带古镇客家会馆群(清代古建),鲁杰现场解构斗拱与营造法式 → "论道"沙龙 → 宽窄巷子夜话·私宴。' },
|
||||
{ day: 5, title: '历史建筑·故事收官', desc: '安仁公馆群 / 都江堰-青城山古建,专家讲背后的家族与时代故事 → 结业茶会,赠"数字传承卡"。' },
|
||||
],
|
||||
masterMoments: ['D1 朱成茶叙破冰', 'D2 朱成馆长亲自导览 + 私宴论道', 'D3 李江面对面:示范、点评习作', 'D4 鲁杰论道:古建营造现场解构'],
|
||||
guides: [
|
||||
{ name: '朱明', note: '朱成之子', titles: ['博物馆研究博士', '一人博物馆创始人', '成都美术家协会副秘书长'] },
|
||||
{ name: '熊婧彤', titles: ['省级青年建筑专家', '教授级高工', '成都历史建筑口述编导', '主播', '成都美术家协会副秘书长'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '姑苏·园林与绣艺之旅(4天·私宴接待级)', region: '苏州', tier: 'private',
|
||||
priceHint: 38000, depositRate: 0.5, seats: 8,
|
||||
masters: [['shenyun-su', 1], ['chenyuan-su', 1]],
|
||||
highlights: ['苏绣非遗传承人亲授 + 园林营造大师同行', '拙政园旁精品园景酒店 · 专属管家', '昆曲雅集 · 数字分身行前讲解 + 行后问道'],
|
||||
included: ['3 晚精品园景住宿', '全程专属管家', '苏绣工坊亲授', '名园深度私导 + 昆曲雅集', '数字传承卡'],
|
||||
itinerary: [
|
||||
{ day: 1, title: '入苏·序章:平江烟雨', desc: '专属管家接机 → 入住园景酒店 → 拙政园·平江路人文漫步 → 欢迎晚宴。' },
|
||||
{ day: 2, title: '沈云·一针一世界', desc: '苏绣非遗工坊,沈云大师亲授看绣/试针 → 论"以针代笔"的意境 → 苏帮菜私宴。' },
|
||||
{ day: 3, title: '陈园·园林之道', desc: '网师园/留园,陈园现场解构叠山理水、移步换景 → 夜游拙政园·昆曲雅集。' },
|
||||
{ day: 4, title: '姑苏·故事收官', desc: '虎丘/寒山寺,专家讲背后故事 → 结业茶会 → 送机。' },
|
||||
],
|
||||
masterMoments: ['D2 沈云亲授苏绣 + 私宴', 'D3 陈园园林论道 + 昆曲雅集'],
|
||||
},
|
||||
{
|
||||
title: '徽州·古村与文房之旅(5天·私宴接待级)', region: '徽州', tier: 'private',
|
||||
priceHint: 45000, depositRate: 0.5, seats: 8,
|
||||
masters: [['humo-hui', 1], ['fangying-hui', 1]],
|
||||
highlights: ['徽墨非遗传承人亲授 + 徽派建筑大师同行', '宏村旁徽派古宅精品酒店 · 专属管家', '世界文化遗产古村落深度私导'],
|
||||
included: ['4 晚徽派古宅住宿', '全程专属管家', '徽墨/歙砚工坊亲授', '宏村·西递深度私导', '数字传承卡'],
|
||||
itinerary: [
|
||||
{ day: 1, title: '入徽·序章:屯溪老街', desc: '专属管家接机 → 入住徽派古宅酒店 → 屯溪老街人文漫步 → 欢迎晚宴。' },
|
||||
{ day: 2, title: '胡墨·文房四宝', desc: '徽墨/歙砚工坊,胡墨大师亲授点烟描金制墨、赏砚 → 论文房之道 → 徽菜私宴。' },
|
||||
{ day: 3, title: '方营·粉墙黛瓦', desc: '宏村/西递古村落,方营现场解构徽派三雕与马头墙、天井 → 南湖书院茶叙。' },
|
||||
{ day: 4, title: '徽州人文·宗族故事', desc: '徽州古城/棠樾牌坊群,专家讲徽商与宗族的时代故事。' },
|
||||
{ day: 5, title: '徽州·山水收官', desc: '黟县木坑竹海 → 结业茶会,赠"数字传承卡" → 送机。' },
|
||||
],
|
||||
masterMoments: ['D2 胡墨亲授制墨 + 私宴', 'D3 方营徽派建筑现场论道 + 书院茶叙'],
|
||||
},
|
||||
];
|
||||
|
||||
// ── 众生墙:普通人示例分身(非大师,isMaster:false)──
|
||||
const PEOPLE = [
|
||||
{ slug: 'linxiaoman', displayName: '林小满', tagline: '一杯手冲,慢煮生活', tags: ['咖啡', '手冲', '独立小店', '生活方式'], likeCount: 7, likeSum: 32,
|
||||
characterPrompt: '我是林小满,开了家只有八个座位的独立咖啡馆。我聊手冲、豆子、慢生活,也聊一个人开小店的酸甜。语气温柔、不急不躁。' },
|
||||
{ slug: 'chenjiu', displayName: '陈九', tagline: '写代码的深夜,和你聊技术也聊人生', tags: ['程序员', '技术', '副业', '效率'], likeCount: 9, likeSum: 40,
|
||||
characterPrompt: '我是陈九,写了八年后端。能陪你聊架构、聊 bug、聊程序员的副业和焦虑。理性、爱打比方,偶尔毒舌。' },
|
||||
{ slug: 'suqing', displayName: '苏晴', tagline: '呼吸之间,找回身体的觉知', tags: ['瑜伽', '冥想', '身心', '疗愈'], likeCount: 6, likeSum: 29,
|
||||
characterPrompt: '我是苏晴,教瑜伽和冥想。我引导你把注意力放回呼吸和身体,聊体式,也聊如何和自己相处。温和、笃定。' },
|
||||
{ slug: 'amay', displayName: '阿May', tagline: '记录女儿的每一天,也记录我自己', tags: ['育儿', '亲子', '成长记录', '日常'], likeCount: 8, likeSum: 36,
|
||||
characterPrompt: '我是阿May,一个一岁女儿的新手妈妈。分享育儿的手忙脚乱和小确幸,也想被人问问"你自己还好吗"。真诚、有点碎碎念。' },
|
||||
{ slug: 'laozhou', displayName: '老周', tagline: '退休了,想把读过的书讲给你听', tags: ['读书', '文史', '退休生活', '讲书'], likeCount: 5, likeSum: 24,
|
||||
characterPrompt: '我是老周,教了三十五年语文,刚退休。爱讲书、讲诗词、讲历史里的小人物,也想有人陪我聊聊天。慈祥、爱掉书袋。' },
|
||||
{ slug: 'dapeng', displayName: '大鹏', tagline: '别急,我们从第一个深蹲开始', tags: ['健身', '增肌', '减脂', '自律'], likeCount: 7, likeSum: 30,
|
||||
characterPrompt: '我是大鹏,健身教练。陪你从零开始练,聊动作、聊饮食、聊怎么坚持下去。热血、直接、爱鼓励人。' },
|
||||
{ slug: 'xiawan', displayName: '夏晚', tagline: '背包走过 23 国,故事讲不完', tags: ['旅行', '摄影', '背包客', '攻略'], likeCount: 9, likeSum: 43,
|
||||
characterPrompt: '我是夏晚,背包走过二十多个国家。聊路上的故事、省钱攻略、一个人旅行的胆量与孤独。松弛、爱讲段子。' },
|
||||
{ slug: 'linyisheng', displayName: '林医生', tagline: '有些话,先说给我的分身听听', tags: ['心理', '情绪', '倾诉', '疗愈'], likeCount: 8, likeSum: 38,
|
||||
characterPrompt: '我是林医生,心理咨询师。不评判,只陪你把情绪慢慢说清楚。温和、会倾听、常反问。注:我不做诊断,严重困扰请找线下专业帮助。' },
|
||||
{ slug: 'xiaoye', displayName: '小野', tagline: '把家的味道,做给更多人', tags: ['美食', '家常菜', '下厨', '治愈'], likeCount: 6, likeSum: 27,
|
||||
characterPrompt: '我是小野,爱做家常菜。聊怎么把一顿普通饭做得有温度,分享菜谱和厨房里的治愈时刻。热心、烟火气十足。' },
|
||||
{ slug: 'azheng', displayName: '阿正', tagline: '教室到舞房,都是我的主场', tags: ['街舞', '校园', '青春', '兴趣'], likeCount: 7, likeSum: 31,
|
||||
characterPrompt: '我是阿正,大三,跳街舞。聊舞蹈、校园、怎么在忙碌里坚持热爱。活力满满、爱用网络梗。' },
|
||||
];
|
||||
|
||||
async function run() {
|
||||
await connectDb();
|
||||
const db = getDb();
|
||||
|
||||
// 幂等:清掉上次种子(只清本 seedTag 相关,不动后台手工建的数据)。
|
||||
const oldTwins = await db.collection('twins').find({ seedTag: SEED }).project({ _id: 1 }).toArray();
|
||||
const oldIds = oldTwins.map((t) => t._id);
|
||||
const oldMasters = await db.collection('masters').find({ twinId: { $in: oldIds } }).project({ _id: 1 }).toArray();
|
||||
await db.collection('agreements').deleteMany({ masterId: { $in: oldMasters.map((m) => m._id) } });
|
||||
await db.collection('twins').deleteMany({ seedTag: SEED });
|
||||
await db.collection('masters').deleteMany({ twinId: { $in: oldIds } });
|
||||
await db.collection('tours').deleteMany({ seedTag: SEED });
|
||||
|
||||
const twinIdBySlug = {};
|
||||
for (const m of MASTERS) {
|
||||
const twinDoc = {
|
||||
ownerId: new ObjectId(),
|
||||
displayName: m.displayName, kind: 'clone', persona: '', characterPrompt: m.characterPrompt,
|
||||
avatarUrl: null, voiceId: null, voiceModel: null, lang: 'zh',
|
||||
isPublic: true, inPlaza: true, slug: m.slug, tagline: m.tagline, tags: m.tags,
|
||||
verified: true, isMaster: true, masterTier: m.masterTier, domain: m.domain, region: m.region,
|
||||
honorific: m.honorific, masterworks: m.masterworks, profession: null, videoUrl: m.videoUrl || null,
|
||||
talents: { sub_talents: [], unlockedAt: null }, likeSum: 0, likeCount: 0,
|
||||
seedTag: SEED, createdAt: new Date(), updatedAt: new Date(),
|
||||
};
|
||||
const r = await db.collection('twins').insertOne(twinDoc);
|
||||
twinIdBySlug[m.slug] = r.insertedId;
|
||||
const mr = await db.collection('masters').insertOne({
|
||||
twinId: r.insertedId, status: 'live',
|
||||
// ⚠ knowledge 必须是 false。这个脚本【一处知识库都没写】(全文 0 处 ingest)——
|
||||
// 种子大师只有一句 characterPrompt 人设,知识库是空的。
|
||||
// 此前这里写死 knowledge:true,等于代码在对自己的仪表盘撒谎:后台看到"知识库已就绪",
|
||||
// 实际上任何人派分身来"向大师学习",学到的都只是 Qwen 借着人设即兴生成的通识。
|
||||
// 要让它变成 true,只有一条路:真的把大师的资料喂进去(masters.js addMasterKnowledge)。
|
||||
buildProgress: { photo: true, voice: true, knowledge: false, review: true },
|
||||
poolWeight: m.poolWeight, offlineCommit: '每团出席茶叙/论道 1 次;私宴按排期配合。',
|
||||
intro: m.characterPrompt, contact: {}, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
await db.collection('twins').updateOne({ _id: r.insertedId }, { $set: { masterId: mr.insertedId } });
|
||||
await db.collection('agreements').insertMany([
|
||||
{ masterId: mr.insertedId, kind: 'authorization',
|
||||
scope: { useImage: true, useVoice: true, useKnowledge: true, canSay: [m.domain], cannotSay: ['政治', '医疗诊断', '投资建议'] },
|
||||
revocable: true, term: { from: new Date(), to: null },
|
||||
inheritance: '授权范围内,数字分身可在本人年迈/离世后继续用于文化传承讲解。',
|
||||
aiDisclosure: '本页为大师授权的数字分身,非本人实时应答;深度交流以线下为准。',
|
||||
signedAt: new Date(), createdAt: new Date() },
|
||||
{ masterId: mr.insertedId, kind: 'cooperation',
|
||||
cooperationSLA: { teaPerTour: 1, banquetPerYear: 4, receptionNote: '按团配合接待、茶叙/论道,私宴择期。' },
|
||||
createdAt: new Date() },
|
||||
]);
|
||||
}
|
||||
|
||||
for (const t of TOURS) {
|
||||
const ids = t.masters.map(([slug]) => twinIdBySlug[slug]);
|
||||
await db.collection('tours').insertOne({
|
||||
title: t.title, cover: null, region: t.region, masterIds: ids, tier: t.tier,
|
||||
priceHint: t.priceHint, depositRate: t.depositRate,
|
||||
revShare: { platform: 20, masters: 30, organizer: 50 },
|
||||
masterShares: t.masters.map(([slug, weight]) => ({ masterId: twinIdBySlug[slug], weight })),
|
||||
seats: t.seats, seatsLeft: t.seats, status: 'open',
|
||||
highlights: t.highlights, included: t.included, itinerary: t.itinerary, masterMoments: t.masterMoments,
|
||||
guides: t.guides || [],
|
||||
seedTag: SEED, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 众生墙:普通人示例分身 ──
|
||||
for (const p of PEOPLE) {
|
||||
await db.collection('twins').insertOne({
|
||||
ownerId: new ObjectId(),
|
||||
displayName: p.displayName, kind: 'clone', persona: '', characterPrompt: p.characterPrompt,
|
||||
avatarUrl: null, voiceId: null, voiceModel: null, lang: 'zh',
|
||||
isPublic: true, inPlaza: true, slug: p.slug, tagline: p.tagline, tags: p.tags,
|
||||
verified: false, isMaster: false, profession: null,
|
||||
talents: { sub_talents: [], unlockedAt: null }, likeSum: p.likeSum, likeCount: p.likeCount,
|
||||
seedTag: SEED, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✓ 种子完成');
|
||||
console.log(' 大师:', MASTERS.map((m) => `${m.displayName}(${m.region})`).join('、'));
|
||||
console.log(' 路线:', TOURS.map((t) => `${t.region}¥${t.priceHint}`).join('、'));
|
||||
console.log(' 众生墙普通人:', PEOPLE.map((p) => p.displayName).join('、'));
|
||||
await closeDb();
|
||||
}
|
||||
|
||||
run().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user