// qian.js —— 迁转。★ 规矩写在注释里没用,写成断言才算数。 // // ⚠ 本文件扫的是【数据与行为】,不是注释。 // (照 test/dynasties-shape.test.js 的先例:「它扫的是【数据】,不是注释」。) import test from 'node:test'; import assert from 'node:assert/strict'; import { QIAN_YUANWEN, ZERO_HITS, ONE_PERSON, LAO_ALWAYS_BROKEN, THE_CLOCK, STOP_KINDS, STRATA, HAN_LADDER_SNAPSHOT, qianPath, assertQian, assertSnapshot, FINDINGS, NOT_DONE, } from '../src/qian.js'; import { computeAxes, DYNASTIES } from '../src/dynasties.js'; import { SUB_REGISTRY } from '../src/talents.js'; // ⚠ SUB_REGISTRY 是【数组】。★ 本刀在穷举驱动上栽过一次:写了 Object.keys() 拿到下标 "0".."15", // 于是六轴全 40、汉的三条闸全关 —— 【结论没变(0 步),而理由整个是假的】。见 qian.js NOT_DONE ④。 const SUBS = SUB_REGISTRY.map((s) => s.id); test('★ 体例自检:assertQian() 零违规', () => { assert.deepEqual(assertQian(), []); }); test('★ 抄件不许漂:HAN_LADDER_SNAPSHOT 与 dynasties.js 的真梯子逐格一致', () => { assert.deepEqual(assertSnapshot(), []); }); test('★ 每一条料都必须标股别(正文/本注/劉昭注/政书)—— 没有股别的料不许存在', () => { const ids = Object.keys(STRATA); for (const r of QIAN_YUANWEN) { assert.ok(ids.includes(r.stratum), `${r.id} 的股别「${r.stratum}」不在 STRATA 里`); assert.ok(r.source && r.quote, `${r.id} 没有 source/quote`); assert.ok(r.caveat, `${r.id} 没有 caveat —— 限定语才是注的价值`); } }); test('★★ 单证必须自己说出「单证」—— 假单证比假零命中更阴险', () => { for (const r of QIAN_YUANWEN) { if (r.witnesses === 1) { assert.match(String(r.witnessNote || ''), /单证/, `${r.id} 是单证却没说`); } } }); test('★★★ 推演不许带卷次 —— 带卷次的错误不可反驳', () => { for (const [name, o] of [['ONE_PERSON', ONE_PERSON], ['LAO_ALWAYS_BROKEN', LAO_ALWAYS_BROKEN]]) { assert.equal(o.kind, 'tuiyan', `${name} 应当标 kind='tuiyan'`); assert.match(o.noJuanci, /不给.{0,4}安卷次/, `${name} 没明说不给它安卷次`); } }); test('★★★★ 头号读数:全表【唯一一条】迁转规则不需要第二个人,而它两头都不在梯子上', () => { const rules = QIAN_YUANWEN.filter((r) => r.from && r.to); const solo = rules.filter((r) => r.needsSecondPerson === false); assert.equal(solo.length, 1, '「一个人就能走」的迁转规则应当正好 1 条'); assert.equal(solo[0].id, 'guan_yezhe'); // ★ 起点 = 梯子 tier2(逐字「郎,比三百石」);终点 = null(四百石,梯子上没有) assert.equal(solo[0].from.tier, 2, '起点应当是 tier2 —— 它变了,「起点不可达」要重读'); assert.equal(solo[0].to.tier, null, '终点应当是 null —— 它变了,「终点不存在」要重读'); const t2 = HAN_LADDER_SNAPSHOT.ladder.find((l) => l.tier === 2); assert.equal(t2.grade, '比三百石', '梯子 tier2 的秩石应当与那条迁转的起点逐字相同'); // ★★ 而四百石【真的不在梯子上】—— 这一句是「终点不存在」的实证,不是我的印象 assert.ok( !HAN_LADDER_SNAPSHOT.ladder.some((l) => l.grade === '四百石'), '梯子上不该有「四百石」—— 它一旦有了,本表的头号结论要重读' ); }); test('★★ 零命中必须带【非零的对照词】—— 没有对照的零命中,证明的是检索坏了', () => { assert.match(ZERO_HITS.notZero, /殿最/); for (const w of ZERO_HITS.words) assert.equal(w.hits, 0, `${w.w} 不是 0 就不该在这张表上`); }); test('★★★ 老板红线③:数据里不许出现随机/抽卡这类词,代码里不许有 Math.random', async () => { const json = JSON.stringify({ QIAN_YUANWEN, ONE_PERSON, THE_CLOCK, ZERO_HITS, STOP_KINDS }); for (const w of ['随机', '抽卡', '概率', '掷骰']) { assert.ok(!json.includes(w), `数据里出现「${w}」`); } // ★ 扫源码:纯函数拿不到自己的源码,故由测试来扫。 // // ⚠⚠ 【而这一条【必须先把注释剥掉】—— 它当场抓了我一次,记在这里】: // 我第一版直接扫全文,于是 qian.js 里那句【「本函数没有 Math.random,也永远不许有」】 // —— 一句【禁令】—— 被扫描器判成了违规。 // ★ 这与 lao.js 记的是【同一族】,只是方向反了:那次是【数据里为了禁它而提到它】 // (「⇒ 扫描器是对的,我是错的:它分不出「使用」与「禁止」,而它【本来就不该分】—— // 一个会走到用户眼前的串里出现那个词,不管出于什么用意,都是红线。」); // ⇒ 而本项目的体例逐字是:【禁令写在注释里(注释不被扫),数据里一个字都不许有】。 // ⇒ ★ 所以【是我的扫描器越界了】:它读了那个【专门用来放禁令的地方】。 // ⇒ 剥注释,再扫。⇒ 数据那一半(上面那个 JSON.stringify)仍然一个字都不许有。 const { readFileSync } = await import('node:fs'); const { fileURLToPath } = await import('node:url'); const raw = readFileSync(fileURLToPath(new URL('../src/qian.js', import.meta.url)), 'utf8'); const code = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); assert.ok(!/Math\.random/.test(code), 'qian.js 的【代码】里出现 Math.random —— 随机 = 抽卡 = 版号 = 死'); }); test('★ qianPath 是纯函数:同一份输入跑两次必须完全相同', () => { const p = { axes: computeAxes(['rigorous', 'concise']), wang: 60 }; const a = qianPath(p, 'han', { lao: 4000 }); const b = qianPath(p, 'han', { lao: 4000 }); assert.deepEqual(a, b); }); test('★ 失效方向朝关:问一个不存在的朝代,答 null(不许兜底挑一朝)', () => { assert.equal(qianPath({ axes: computeAxes([]) }, 'TYPO', { lao: 100 }), null); }); test('★★ 劳 → 歲 是本表的换算,且它【不进任何一个分】:单位是歲,不是 0–100', () => { assert.equal(THE_CLOCK.suiOf(0), 0); assert.equal(THE_CLOCK.suiOf(364), 0); assert.equal(THE_CLOCK.suiOf(365), 1); assert.equal(THE_CLOCK.suiOf(36500), 100); // ★ 劳是天,不许被 clamp 到 100 —— 让单位本身挡住「跟六轴加起来平均」那件事(lao.js 的口径) const r = qianPath({ axes: computeAxes([]) }, 'han', { lao: 36500 }); assert.equal(r.lao.days, 36500, '劳不许被 clamp 到 100'); assert.equal(r.lao.sui, 100); }); test('★★ 另外六朝的 0 步,与汉的 0 步【不是一回事】—— stop kind 必须分得开', () => { for (const d of DYNASTIES) { const r = qianPath({ axes: computeAxes(['rigorous']), wang: 60 }, d.id, { lao: 4000 }); const kinds = r.stopped.map((s) => s.kind); if (d.id === 'han') { assert.ok(!kinds.includes('no-rule'), '汉不该报「本表没有这一朝的原文」—— 本刀抠了它'); assert.ok(kinds.includes('needs-second-person'), '汉必须报【要第二个人】那一条'); } else { assert.ok(kinds.includes('no-rule'), `${d.id} 的 0 步是【我没干】,必须这么说`); } } }); // ══════════════════════════════════════════════════════════════════════════ // ★★★★★ 实跑 —— 「0 步」不许只是推出来的。 // // ⚠ 全量是【2^16 × 8 wang × 8 劳 × 7 朝 = 29,360,128 次】(本刀跑过,0 步,零例外)。 // CI 里跑缩版:2^16 画像 × 2 wang × 3 劳(含跨「滿歲」两侧)× 汉 = 393,216 次。 // // ★★ 而这个测试【必须同时断言一个我知道答案的对照量】—— // 否则它会像本刀第一版那样:【打印出对的结论,而理由整个是假的】。见 qian.js NOT_DONE ④。 // 对照量 = 【汉的起点 tier 必须取到 {0, 3}】(kaoyan.js CONSTANT_PROOF 的解析结论)。 // 它一旦是 {0},就说明画像根本没进去 —— 那这场实验什么都没测。 // ══════════════════════════════════════════════════════════════════════════ test('★★★★★ 穷举实跑:汉 —— 起点 tier 只取到 {0,3}(★ 2 一次都不出现),而迁转恒 0 步', () => { const startTiers = new Set(); let runs = 0; let anyStep = 0; for (let mask = 0; mask < 2 ** SUBS.length; mask++) { const subs = SUBS.filter((_, i) => mask & (1 << i)); const axes = computeAxes(subs); for (const wang of [0, 60]) { for (const lao of [364, 365, 36500]) { const r = qianPath({ axes, wang }, 'han', { lao }); runs += 1; startTiers.add(r.start.tier); if (r.steps.length) anyStep += 1; } } } // ⚠ 2026-07-17 加经学轴(talents.js 第 17 项 jingxue_tong):副天赋从 16 变 17 ⇒ 穷举底数 2^16 → 2^17。 // ★ 这是【真撞点】:SUBS.length 真的变了。jingxue_tong 对【汉】的六轴全无影响(它只落 jingxue 轴, // 而汉的三条闸看 jiao/wang/fa/wu),⇒ 每个旧组合分裂成【含/不含 jingxue_tong】两份、汉的结果逐份相同 // ⇒ 起点 tier 仍只取到 {0,3}(下面那条断言未动)。变的只是组合数翻倍。 // ⚠ 2026-07-17 拆墙:穷举底数从引擎派生(2^SUBS.length),不写死 2^17 —— talents.js 加副天赋就自动跟上。 assert.equal(runs, 2 ** SUBS.length * 2 * 3); // ★ 对照量:画像真的进去了没有 —— 这一条必须先过,否则下面那条断言毫无意义 assert.deepEqual([...startTiers].sort((a, b) => a - b), [0, 3], '汉的起点 tier 应当是 {0,3} —— 若是 {0},说明画像没进去,这场实验作废'); // ★★★ 头号结论:tier2 一次都不出现 ⇒ 那条唯一的迁转,起点对所有人不可达 assert.ok(!startTiers.has(2), '★ 没有一个人落在 tier2 —— 这就是「起点不可达」'); assert.equal(anyStep, 0, '★★ 汉:一步迁转都走不出来,零例外'); }); test('★ FINDINGS / NOT_DONE 必须是【报告】,不是【许愿】:每条都得有落点', () => { for (const f of FINDINGS) { assert.ok(f.title && f.where && f.finding && f.proposal, `FINDINGS ${f.id} 不完整`); assert.equal(f.changed, false, '★ 本刀不许改别人的文件 —— changed 必须全 false'); } for (const n of NOT_DONE) assert.ok(n.title && n.detail, `NOT_DONE ${n.id} 不完整`); });