189 lines
11 KiB
JavaScript
189 lines
11 KiB
JavaScript
// 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} 不完整`);
|
||
});
|