Files
digital-me-history/digital-me-backend/test/decisions.test.js
T
admin 0593ff7e62
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
数字我 Digital Me:全量入库(大文件走 Git LFS)
2026-10-10 16:24:43 +08:00

2120 lines
139 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 决策点 + 史臣曰的单测 —— 与 dynasties.test.js 同样的性质:
// 这不是「覆盖率」测试,这是【授权律的可执行定义】。
//
// 第三刀的立足点是一句话:
// 「你个人的下场,和你那一念的深度,是反相关的;
// 而制能否存活,取决于后继者是否仍需要它——那一票不在你手上。」
// 下面 3、4 两组断言就是这句话的可执行形式。挂了就说明数据在偷偷违反自己的结论。
//
// 跑:node --test test/decisions.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DECISIONS, LAYERS, IMPACT_TIERS, INITIAL_VALUES, SERIAL_NOTE, STATE_KEYS,
getDecision, listDecisions, canReach, computeImpact, assertInvariants, layerDepth,
ref, replay, stateOf, availableDecisions, preconditionsOf,
openLineage, appendDecision, anotherEdition, rankSnapshot, assertNoGameMechanics,
HAN_DECISIONS, NANQI_DECISIONS, WEIJIN_DECISIONS,
TANG_DECISIONS, SONG_DECISIONS, MING_DECISIONS, LIAO_DECISIONS, ZHIDU_DECISIONS,
} from '../src/decisions.js';
import * as Dec from '../src/decisions.js';
import { computeRank, profileFromSubs, sameScale, comparableTier } from '../src/dynasties.js';
import {
ZAICHANG, zaichangFor, zaichangFromEvents, gongqingZaichangFor, assertZaichang,
zaichangFromTianxia,
} from '../src/zaichang.js';
import { shizanYue, shizanForLineage, validateZan, formatZan, assertTiyuan, assertYun, shuMingOf, tiyuanFor } from '../src/shizan.js';
import { buildZhuan } from '../src/zhuan.js';
// 样张那个人:严谨/有理有据/简洁/结论先行/耐心/沉稳,无声望。
// 与 dynasties.test.js 的 CLERK 是同一个人——两刀共用一个样板人,对照才立得住。
const CLERK = ['rigorous', 'datadriven', 'concise', 'shortfirst', 'patient', 'calm'];
const clerk = () => profileFromSubs(CLERK, { wang: 0 });
// ══════════════════════════════════════════════════════════════════════════
// ★★ 2026-07-17 第六刀:南齐的传现在【从永明八年那一趟江起】,不再从建武的四户起。
//
// D0(宣旨慰劳,永明八年 490)→ D(四户,建武 494–498)→ D2(乞作士大夫,建武末)
//
// ⇒ 【为什么本文件下面几十条断言只改了一个 helper】:
// 它们测的是【关口一与关口二的串联】(状态量/永久关闭/前序),
// 而那件事一个字都没变 —— 变的只是「怎么走到关口一」。
// ⇒ ★ 故 open1() 的语义仍然是「一部【正站在关口一面前】的传」,
// 只是它现在得先过永明那一趟江。
// ⚠ 用 'bu-ken-wang'(历史答案:茹法亮不肯往)当那一步,理由是它【什么都不改】:
// effects 的 quan 是 0 ⇒ 关口二的 state:{quan:0} 那一关不受它影响
// ⇒ 下面所有关于「守住那一户才开得了第二道口」的断言,测的仍然是【关口一的那个选择】,
// 不是被这一步带进来的。★ 换成别的选项这些断言照样成立(本文件另有一条专门证它)。
// ══════════════════════════════════════════════════════════════════════════
const D0 = 'nanqi-xuanzhi';
const D = 'nanqi-sheren-simen';
const D2 = 'nanqi-qi-shidafu';
// 南齐的他:中书通事舍人,tier 4 —— 三个关口都只发给他。
const nc = () => computeRank(clerk(), 'nanchao');
// ★ open0():刚开传的那一刻(手上只有 D0)。
const open0 = () => openLineage({ subjectId: 'u', rank: nc() });
// ★ open1():【正站在关口一面前】的那一部传 —— 过了永明八年那一趟江。
const open1 = () => appendDecision(open0(), D0, 'bu-ken-wang');
const idsOf = (L) => availableDecisions(stateOf(L)).map((x) => x.id);
const traceOf = (L) => L.chain.map((c) => c.optionId).join('→');
// 穷举这一朝【所有走得完的链】。确定性 DFS,不掷骰。
// ★ 拿它当性质测试的输入:韵脚撞车、句库缺条这类坑,只有把每一部传都跑一遍才抓得住。
function allLineages(rank) {
const out = [];
const walk = (L) => {
if (L.closed) { out.push(L); return; }
for (const d of availableDecisions(stateOf(L))) {
for (const o of d.options) walk(appendDecision(L, d.id, o.id));
}
};
walk(openLineage({ subjectId: 'u', rank }));
return out;
}
// ── 1. ★ 位阶即杠杆:够不到的人根本碰不到这个关口 ──
test('位阶不够的人拿不到【别的朝】的决策点', () => {
// 魏晋的他:三条路一条都不开,不入流(channel=null,tier0)。
const wj = computeRank(clerk(), 'weijin');
assert.equal(wj.channel, null);
// ══════════════════════════════════════════════════════════════════════════
// ★★ 2026-07-17 论赞刀:魏晋体例做完、摊进 DECISIONS 后,这条断言【翻案了】——
// 而这正是 decisions.js 早就预言的那一步:tier0 那道题 orNoChannel+tier0 会命中不入流的他。
// ⇒ 不入流的魏晋人【拿到了他自己那道 tier0 题(争乡品)】—— 这正是「位阶决定你拿到【哪一种】题」,
// 不是「够不到就没有」。但他【拿不到南朝/别朝的题】—— 朝代先挡住。
// ══════════════════════════════════════════════════════════════════════════
const wjq = listDecisions(wj).map((d) => d.id);
assert.deepEqual(wjq, ['weijin-t0-zheng-pin'], '★ 不入流的魏晋人拿到的是他自己那道 tier0 题(争乡品,orNoChannel)');
assert.ok(
!wjq.includes('nanqi-xuanzhi') && !wjq.includes(D),
'★ 而他【拿不到南朝舍人那道题】—— 位阶不够是一层,朝代不对更是一层'
);
// 南朝的他:中书通事舍人,tier 4 —— 这道题只发给他。
const nc = computeRank(clerk(), 'nanchao');
assert.equal(nc.channel.id, 'sheren');
assert.equal(nc.rank.tier, 4);
const got = listDecisions(nc);
// ★ 第六刀:listDecisions 是【开局】手上的那些 —— 而开局那道题现在是 D0(永明八年那一趟江)。
// ⚠ 这条测试断言的是【位阶】那一关,不是【哪道题】:够得到那一格的人开局有且只有一道题,
// 够不到的人一道都没有。⇒ 结论一个字没动,变的只是那道题叫什么。
assert.equal(got.length, 1);
assert.equal(got[0].id, D0, '开局那道题是永明八年的「宣旨慰劳」——建武的四户排在它后面');
});
// ══════════════════════════════════════════════════════════════════════════
// ★★★ 2026-07-17 第四刀:这条测试的【前提改了,结论一个字没动】。改的过程记在这里。
//
// 【它原来的前提】`assert.equal(noble.rank.tier, 4, '前提:他的位阶与舍人一样高')`
// —— 它要的是一个【位阶一样高、却不在那一户里】的人,用来证明「杠杆是位阶之外的东西」。
//
// 【为什么那个前提没了 —— 它是一个错误喂出来的】
// ★ 旧引擎(加权求和)让 222 个门第高的士族落在 tier4 上,而那条【共用梯子】的 tier4
// 逐字就是【中书通事舍人】。⇒ 「士族的位阶与舍人一样高」这句话,当年之所以为真,
// 是因为引擎把士族【放进了舍人的那一格】—— 即:它为真的方式,正是【江斅当了纪僧真】。
// ⇒ 这个前提不是被这一刀弄丢的,是它【本来就是那个 bug 的产物】。
//
// 【那「同高而不同户」回来了吗 —— 没有,而且它【不可能】回来】
// 南朝三轨之后:士族在【自己的】梯子上是 tier3(给事黄门侍郎),舍人在【他自己的】梯子上是 tier4。
// ★★ 而更要紧的是:「他俩一样高吗」这个问题【现在问不出口了】——
// 两条梯子 sameScale()=false,士族的 tier4 是【侍中】,舍人的 tier4 是【中书通事舍人】,
// 两个 4 只是碰巧都用这个数字。「谁高」跟「我在南朝相当于汉朝几品」是同一种错。
//
// 【★ 而新前提证的东西比旧的更强,这才是改它的理由 —— 不是为了让它变绿】
// 旧前提说:「他一样高,却拿不到」⇒ 杠杆 ≠ 高度。
// 新前提说:「他的高度和舍人的高度【根本不在同一根轴上】,而他照样拿不到」
// ⇒ 杠杆不但不是高度,【高度这个问题在这里压根问错了】。
// ★★ 而这正是南齐这一朝要说的那句话:士庶天隔【不是高低之隔,是两把尺之隔】。
// 纪僧真什么都有了(恩幸、官、钱、婚姻,连「容表有士风」史书都夸了)——
// 他缺的从来不是【高度】,他缺的是【另一把尺上的一格】,而那把尺不认他。
// 「士大夫故非天子所命」:天子能给你高度,给不了你那把尺。
// ══════════════════════════════════════════════════════════════════════════
test('★ 位阶不在同一根轴上,且不在那一户里的人,照样拿不到 —— 杠杆得先握在你手里', () => {
// 南齐的士族:文采+门第,走清途。
const noble = computeRank(profileFromSubs(['poetic'], { wang: 90 }), 'nanchao');
const sheren = computeRank(clerk(), 'nanchao');
assert.equal(noble.channel.id, 'shizu', '有门第有文采者,南齐走的是清途');
assert.equal(sheren.channel.id, 'sheren');
// ★ 新前提①:两人【走的是两条梯子】,他们的 tier 不可比 —— 「谁高」这个问题问错了。
assert.equal(noble.rank.unit, 'qingtu', '士族走的是清途那把尺');
assert.equal(sheren.rank.unit, 'sheren', '舍人走的是他自己那把尺');
assert.equal(
sameScale('nanchao', 'qingtu', 'sheren'), false,
'前提:两条梯子不共一把尺 —— 「他俩谁高」这个问题本身是错的(否则这条测试没有意义)'
);
// ★ 新前提②:士族【确实在他自己那条路上走得很好】—— 不是「他太菜所以拿不到」。
// 他是这条路上有门第又有文采的人,而这条路的天花板是尚书令(tier5)。
assert.ok(noble.rank.tier >= 3, '前提:他在自己的路上不低 —— 拿不到题不是因为他不行');
assert.equal(noble.ceiling.tier, 5, '前提:他这条路通到尚书令 —— 比舍人的天花板还高');
// ★★ 结论【一个字没动】。
assert.deepEqual(
listDecisions(noble), [],
'他拿不到这道题 —— 他不在那一户里,他是把床挪开不跟你坐的那个人'
);
});
// ══════════════════════════════════════════════════════════════════════════
// ★★ 跨通道的 tier 不可比 —— chiduo.js CONFLICT #9 的第三个方向(2026-07-17 第四刀补)。
// 本表早已守住【跨朝】(comparabilityNote)与【跨梯】(sameScale),
// ★ 而【跨通道】此前只有一句注释,没有判别式。这一组把那句注释变成可执行的。
// ══════════════════════════════════════════════════════════════════════════
test('★★ 守卫:没有 channelId 的 minTier 是问错了的问题 —— 必须答「不」,不能答「行」', () => {
const nc = computeRank(clerk(), 'nanchao');
assert.equal(nc.rank.tier, 4);
// ★ 种一个错配置进去:只说「要到第四格」,不说是【哪条路上的】第四格。
// 守卫自己不灵,等于没有守卫 —— 所以这条测试必须真的种一个。
const bad = { id: 'x', dynasty: 'nanchao', requiresRank: { dynasty: 'nanchao', minTier: 4 }, options: [] };
assert.equal(
canReach(nc, bad), false,
'★ 「第四格」不是一个地方:士族的 tier4 是侍中,舍人的 tier4 是中书通事舍人,' +
'两条梯子 sameScale()=false。不指定通道的 minTier,是在比一个不可比的数 —— 拒绝回答。'
);
// ⚠ 失效方向【朝关】:宁可这道题谁也拿不到,也不许发给错的人。
// ★ 一个配错了的关口,宁可一个人都发不到 —— 悄悄多开一个关口是这套结构最难查的 bug。
const noble = computeRank(profileFromSubs(['poetic'], { wang: 90 }), 'nanchao');
assert.equal(canReach(noble, bad), false, '士族也一样拿不到 —— 守卫拦的是【问法】,不是【人】');
// 而正当的问法必须仍然答得对 —— 别为了堵洞把函数堵死。
const good = { id: 'y', dynasty: 'nanchao', requiresRank: { dynasty: 'nanchao', channelId: 'sheren', minTier: 4 }, options: [] };
assert.equal(canReach(nc, good), true, '说清楚了是哪条路上的第四格 ⇒ 照常回答');
assert.equal(canReach(noble, good), false, '士族不在那一户里 ⇒ 拿不到');
});
test('★★ 数据层也得拦:requiresRank 有 minTier 却没 channelId ⇒ assertInvariants 必须报', () => {
// ★ 守卫(运行时朝关)+ 断言(数据层拒收)两道,缺一不可:
// 缺前者会发错题;缺后者会让错配置一直躺在数据里没人发现 ——
// penaltyIfNoWang 就是那么躺过来的(它在一条名字里写着它、身体里不碰它的测试底下躺了很久)。
assert.deepEqual(assertInvariants(), [], '本表现有的两道关口都必须合规(都写了 channelId)');
// 本表现有的两道关口:确实都拿 channelId 卡的 —— 即这道守卫【现在一个人都拦不到】。
// ⚠ 那它凭什么不是又一个空操作(本轮刚删掉一个)?—— 判别式是【可触发性】,不是【已触发】:
// penaltyIfNoWang 的条件与「路开着」逻辑互斥 ⇒ 永远不可能跑;
// 这道守卫【明天有人写错配置就会跑】,而那正是它要拦的那一天。上一条测试种了一个,它响了。
for (const d of DECISIONS) {
const rq = d.requiresRank || {};
if (typeof rq.minTier === 'number' || typeof rq.maxTier === 'number') {
// ══════════════════════════════════════════════════════════════════
// ★★ 2026-07-17 第五刀(关口·汉):这一条断言【放宽了字段名,收紧了判别式】。
//
// 【为什么动它】原来断的是 `rq.channelId`(单数字段名)。
// 而它断的【本意】写在它自己的失败信息里:「用位阶当门槛,
// 必须先说清楚是哪条路上的位阶」—— ★ 那是【判别式】,channelId 只是它当时唯一的写法。
// 【新写法】汉的六格题要发给「县令那一格的人」,而县令这一格上站着三条路来的人
// (察举/辟召/军功,dynasties.js:「三条通道共一条 ladder」)——
// ⇒ channelIds 把三条路【一条条点了名】。判别式要的那件事一件都没少。
// ⚠⚠ 【而这不是「改测试让它过」—— 判别式反而更严了】,见下一条断言:
// 点了不止一条路时,【只许问「哪一格」(minTier===maxTier),不许问「多高」】。
// ★ 因为跨着几条通道问「多高」,正是 comparableTier 恒答 false 的那件事
// —— 而【本文件下面那条测试正断言着它】:
// `comparableTier('han','xiaolian','bizhao') === false`。
// ⇒ 两条测试现在互相咬着:一条说「汉的两条路 tier 不可比」,
// 一条说「所以点几条路时不许问高低,只许问哪一格」。
// ══════════════════════════════════════════════════════════════════
// ★ 2026-07-22(顺着朝代做下去):anyChannel 也算「说清楚了」—— 它是【签字放弃点名通道】的例外:
// 意思是「本朝这道题不分通道、任何位阶都碰得到(一朝一道那朝的大势)」。
// ⚠ 而它【必须配 requiresRankNote 签字】—— 这一条由 assertInvariants 强制、上面 line 191 的
// `assertInvariants() === []` 断言兜底(漏签字就会让 191 红)。故此处只需认它是「点名过了」。
const named = rq.anyChannel === true || !!rq.channelId || (Array.isArray(rq.channelIds) && rq.channelIds.length > 0);
assert.ok(named, `${d.id}: 用位阶当门槛,必须先说清楚是哪条路上的位阶(channelId / channelIds / anyChannel)`);
assert.ok(rq.dynasty, `${d.id}: 通道 id 只在它那一朝里唯一,必须同时给 dynasty`);
// ★ 点了几条路 ⇒ 只许问「哪一格」(同一),不许问「多高」(排序)。
if (Array.isArray(rq.channelIds) && rq.channelIds.length > 1) {
assert.equal(
rq.minTier, rq.maxTier,
`${d.id}: 点了 ${rq.channelIds.length} 条通道却问「多高」—— ` +
'★ 跨通道比 tier 高低是 comparableTier 恒答 false 的那件事。' +
'点几条路时只许问「哪一格」(minTier === maxTier):那一格叫什么是【梯子答的】,不是比出来的。'
);
assert.equal(typeof rq.minTier, 'number', `${d.id}: 点了几条路,就必须说清楚是【哪一格】`);
}
}
}
});
test('★★ 点了几条通道,就只许问「哪一格」,不许问「多高」(数据层拒收)', () => {
// ★ 种一个错配置进去 —— 守卫自己不灵,等于没有守卫。
// 而它要拦的,正是本刀(关口·汉)自己第一版写错的那个东西:
// 「汉三条路共一条 ladder ⇒ 所以 tier 可比」
// ——★ 那正是 dynasties.js 的 comparableTier 逐字禁的「同 track 就返回 true」近路。
// ⇒ 注释挡不住写的人(他读过那段注还是写错了);这条断言挡得住。
const bad = {
id: 'x', dynasty: 'han', historical: null, historicalNote: '(测试用)',
requiresRank: { dynasty: 'han', channelIds: ['xiaolian', 'bizhao'], minTier: 3 }, // ← 没有 maxTier ⇒ 问「多高」
options: [],
};
const found = assertInvariants([bad]).filter((s) => s.includes('却问「多高」而不是「哪一格」'));
assert.equal(found.length, 1, '★ 跨几条通道问「多高」⇒ assertInvariants 必须报(守卫必须真的会响)');
// ⚠ 而【正当的问法必须仍然答得对】—— 别为了堵洞把函数堵死。
const t3xl = { dynasty: { id: 'han' }, channel: { id: 'xiaolian' }, rank: { tier: 3 } };
const t3bz = { dynasty: { id: 'han' }, channel: { id: 'bizhao' }, rank: { tier: 3 } };
const t5 = { dynasty: { id: 'han' }, channel: { id: 'bizhao' }, rank: { tier: 5 } };
const q3 = { id: 'q', requiresRank: { dynasty: 'han', channelIds: ['xiaolian', 'bizhao'], minTier: 3, maxTier: 3 } };
assert.equal(canReach(t3xl, q3), true, '★ 孝廉出身的县令 —— 坐的是县令那把椅子 ⇒ 拿得到');
assert.equal(
canReach(t3bz, q3), true,
'★★ 辟召出身的县令 —— 同一把椅子,同一道题。【职掌不问你是怎么来的】'
);
assert.equal(canReach(t5, q3), false, '★ 三公拿不到县令那道题 —— 上限也要有(老板点名的那一条)');
});
// ══════════════════════════════════════════════════════════════════════════
// ★★★★ 第六刀:内核缺的那一半 —— 【变的不只是你能爬多高,是你面对的题】
//
// 老板原话逐字:
// 「你什么都没变,天花板全变了。」
// 「★ 变的不只是你能爬多高 —— 是【你面对的题】。
// 汉朝的县令面对「一桩疑狱」;南齐的舍人面对「这一户要不要写进令文」。
// 同一个人,题目完全不同。」
// 「★ 而这一刀要补上内核缺的那一半 —— 【要有一条测试证明它】。」
//
// ⇒ 下面这一组就是那条测试。★ 它【不是**一条**,是三条】,因为老板给的那句话
// 其实压着三个不同的断言,而它们要分开证:
// ① 同一份画像,两朝 ⇒ 题目【本身】不一样(老板要的那条)
// ② 同一朝,不同位阶 ⇒ 题目不一样(老板给的备选,且更基础)
// ③ ★ 同一个【字】(谳),两朝 ⇒ 两道完全不同的题(最硬的那条,见下)
// ══════════════════════════════════════════════════════════════════════════
test('★★★★ 内核:同一份画像,在汉与在南齐,拿到的【题目本身】不一样', () => {
// ★ 同一个人 —— 一个字都没改。这是「你什么都没变」那半句的可执行形式。
const me = clerk();
const inHan = computeRank(me, 'han');
const inNanqi = computeRank(me, 'nanchao');
// ── 先钉住「你什么都没变」:两边喂进去的是同一份画像 ──
assert.deepEqual(computeRank(me, 'han'), inHan, '同一份画像跑两次必须一样(确定性)');
// ── ① 天花板变了(这半句本来就有,dynasties.js 早就证过;这里只取它当前提)──
assert.notEqual(
inHan.channel?.id ?? null, inNanqi.channel?.id ?? null,
'★ 同一个人,两朝走的不是同一条路 —— 这是「天花板全变了」那半句'
);
// ── ②★★ 而【题目本身】也变了 —— 这就是缺的那一半 ──
const hanQ = listDecisions(inHan).map((d) => d.id);
const nanqiQ = listDecisions(inNanqi).map((d) => d.id);
// ★ 南齐:他是中书通事舍人 ⇒ 开局那道题是永明八年那一趟江。
assert.equal(inNanqi.channel.id, 'sheren');
assert.deepEqual(nanqiQ, ['nanqi-xuanzhi'], '★ 在南齐,他面对的是「宣旨慰劳:过不过这条江」');
// ★★★ 两朝的题【没有一道是同一道】—— 这是这条测试的全部内容。
const shared = hanQ.filter((id) => nanqiQ.includes(id));
assert.deepEqual(shared, [], '★★ 同一个人,两朝,题目【一道都不重合】');
// ★★ 2026-07-17 第七刀:汉的《后汉书》体例做完了,HAN_DECISIONS 摊进了 DECISIONS。
// ⇒ 于是 listDecisions(inHan) 【不再是空集】—— 这个人在汉够得到他那一格的题。
// ⚠ 而这恰恰把上面那条「一道都不重合」证得更硬了:以前汉是空集,空集当然不重合(等于没测);
// 现在两边都非空,「不重合」才是一条真的断言。
assert.ok(hanQ.length > 0, '★ 汉那一朝现在发得出题了(体例已齐)—— 这个人在汉够得到他那一格');
// ★ 制度级关口(察举加试·回响B)也摊进了 DECISIONS,且落在汉 tier3 ⇒ 这个 t3 画像够得到它。
const hanReal = [...HAN_DECISIONS, ...ZHIDU_DECISIONS].filter((d) => canReach(inHan, d));
assert.deepEqual(
hanQ.slice().sort(), hanReal.map((d) => d.id).sort(),
'★ 摊进 DECISIONS 后,listDecisions 与直接问 HAN_DECISIONS+ZHIDU_DECISIONS 应当给出同一批题'
);
assert.ok(hanReal.length > 0, '★ 这个人在汉确实够得到题(否则「不重合」是拿空集蒙混)');
for (const d of hanReal) {
assert.ok(!nanqiQ.includes(d.id), `★ ${d.id} 不该同时出现在南齐`);
}
// ★★★★ 而这才是老板那句话的样子:把两边的题面【并排读出来】。
// 汉:他是一个郡的太守/县令这一格上的人,面对的是「这次征粮怎么摊」「这个人举不举」。
// 南齐:他是那一户里的人,面对的是「这条江过不过」。
// ⇒ 同一个人。题目【连问的是什么都不一样】。
const hanTitles = hanReal.map((d) => d.title);
const nanqiTitles = listDecisions(inNanqi).map((d) => d.title);
assert.ok(hanTitles.length && nanqiTitles.length);
assert.deepEqual(
hanTitles.filter((t) => nanqiTitles.includes(t)), [],
'★★ 连题面都不重合 —— 不是「同一道题换个难度」,是【另一道题】'
);
});
test('★★★ 内核·更基础的那一条:同一朝里,位阶不同 ⇒ 拿到的是【不同的题】', () => {
// ★ 老板:「requiresRank 我用反了……正确的是「位阶决定你拿到【哪一种】关口」
// —— 不是「够不到就没有」,是【每一格都有它自己的题】。」
// ⇒ 这条测试就是那句话的可执行形式,而它比上面那条更基础:
// 它连「换一个朝代」都不需要 —— 【同一朝,同一条路,六格六题】。
const at = (tier) => ({
dynasty: { id: 'han' }, channel: { id: 'xiaolian' }, rank: { tier },
});
const seen = new Map();
for (let tier = 0; tier <= 5; tier++) {
const got = HAN_DECISIONS.filter((d) => !d.lite && canReach(at(tier), d)).map((d) => d.id);
assert.ok(got.length > 0, `★ tier${tier} 必须【有它自己的题】—— 「每一格都有它自己的题」`);
seen.set(tier, got);
}
// ★★ 六格拿到的题【两两不重合】—— 位阶决定你拿到【哪一种】,不是【多少】。
for (let a = 0; a <= 5; a++) {
for (let b = a + 1; b <= 5; b++) {
const overlap = seen.get(a).filter((id) => seen.get(b).includes(id));
assert.deepEqual(overlap, [], `★★ tier${a} 与 tier${b} 不该拿到同一道题(${overlap.join(',')})`);
}
}
// ★★★ 反过来也得成立:这【不是】「爬得越高题越多」——
// 斗食小吏(tier0)与三公(tier5)拿到的题【一样多】,只是【不是同一批】。
// ⇒ 位阶不是一个「解锁进度条」,它是【你站在哪一格】。
assert.equal(seen.get(0).length, 1, 'tier0 斗食小吏:一道题');
assert.equal(seen.get(1).length, 1, 'tier1:一道题');
assert.equal(
seen.get(5).length, 2,
'★ tier5 三公两道题(谳疑罪 / 谏争)—— 不是因为他「高」,是因为他那一格的职掌有两条'
);
});
test('★★★★ 内核·最硬的那一条:同一个字(谳),两朝,两道完全不同的题', () => {
// ══════════════════════════════════════════════════════════════════════
// ★★★ 这一条是本刀在读原文时撞见的,而它比上面两条都硬 ——
// 因为它把「变的是你面对的题」缩到了【一个字】上:
//
// · 汉·廷尉(tier5):《续汉书·百官志二》「凡郡国【谳】疑罪,皆处当以报」
// ⇒ 他【判】那桩谳。题目:这个人该不该死。
// · 南齐·中书通事舍人(tier4):《南齐书》卷56 倖臣传序「关【谳】表启,发署诏敕」
// ⇒ 他【经手】那桩谳 —— 他不判,他是那条路。题目:这一件事,办还是不办。
//
// ⇒ ★★ 同一个「谳」字。两朝。一个人【判生死】,一个人【只是过一道手】。
// ⇒ 这就是老板那句话最短的证明:【你什么都没变 —— 而你面对的题,连主语都换了】。
// ⚠ 而这两句都是【原文】,不是我们的话:一句在《续汉书·百官志二》(guankou-han.js,双证),
// 一句在《南齐书》卷五十六(guankou-nanqi.js,当代史,双证)。
// ══════════════════════════════════════════════════════════════════════
const hanYan = HAN_DECISIONS.find((d) => d.id === 'han-t5-yan');
assert.ok(hanYan, '汉·廷尉那道谳的题应当在');
const nqXuanzhi = NANQI_DECISIONS.find((d) => d.id === 'nanqi-xuanzhi');
assert.ok(nqXuanzhi, '南齐·舍人那道题应当在');
// ★ 两边的处境里都真的有那个「谳」字 —— 而它们说的不是同一件事。
const hanText = JSON.stringify(hanYan.situationCites);
const nqText = JSON.stringify(nqXuanzhi.situationCites);
assert.ok(/谳|讞/.test(hanText), '汉那道题的史料里必须有「谳」');
assert.ok(/谳|讞/.test(nqText), '南齐那道题的史料里必须有「谳」');
// ★★ 汉的廷尉【处当】那桩谳(他判);南齐的舍人【关】那桩谳(他经手)。
assert.ok(/處當|处当/.test(hanText), '★ 汉·廷尉:「皆处当以报」—— 他【判】');
assert.ok(/关谳表启|關讞表啟/.test(nqText), '★ 南齐·舍人:「关谳表启」—— 他【经手】');
// ★★★ 而位阶【正好是反的】:判生死的在 tier5,只过一道手的在 tier4 —— 差一格。
// ⇒ 「你能爬多高」这个数,回答不了「你面对的是什么题」。
assert.equal(hanYan.requiresRank.minTier, 5);
assert.equal(nqXuanzhi.requiresRank.minTier, 4);
});
test('★★★ 关口·南齐【事】层:料出《南齐书》当代史,而且【发得出去】', () => {
// ★ 与汉那八道题【正好相反】:这一道摊进了 DECISIONS。
// 判别式(decisions.js 那两个 import 摆在一起就看得出来):【体例齐了才发得出去】。
// 南齐的体例(史臣曰 + 四言赞 + 倖臣别裁)本来就在 shizan.js 里;汉的《后汉书》体例第七刀做完了。
assert.deepEqual(assertInvariants(NANQI_DECISIONS), [], '南齐那道题必须合本表的全部不变量');
assert.ok(
DECISIONS.some((d) => d.id === 'nanqi-xuanzhi'),
'★ 它【在】DECISIONS 里'
);
// ★★ 2026-07-17 第七刀:汉那八道题【也在】DECISIONS 里了 —— 《后汉书》体例做完,判别式过关。
assert.ok(
HAN_DECISIONS.every((d) => DECISIONS.includes(d)),
'★ 汉那八道题现在【也在】DECISIONS —— 体例(论曰 + 职部赞)做完了,不再是八个 500'
);
// ★★ 老板要的「事」层:这道题必须全是【事】—— 舍人这一格没有制可改。
const d = NANQI_DECISIONS.find((x) => x.id === 'nanqi-xuanzhi');
for (const o of d.options) {
assert.equal(o.layer, 'shi', `${o.id}: 舍人这一格的日常题只能是「事」层`);
assert.equal(o.inherited.value, false, '★ 事不可能被继承');
assert.equal(computeImpact(d.id, o.id).years, 0, '★ 事层恒被弥合 —— 五十年内抹平');
}
// ★★★ 而【历史答案是真的】,不是编的:茹法亮不肯往,且连传诏都不遣。
assert.equal(d.historical, 'bu-ken-wang');
const cites = JSON.stringify(d.historicalCites);
assert.ok(/疑畏不肯往/.test(cites), '★ 原文:法亮疑畏不肯往');
assert.ok(/又求见传诏,法亮又不遣/.test(cites), '★ 原文:第二次拒绝 —— 连传诏都不遣');
// ★★★★ 老板要的蝴蝶效应:那个「故」字【是萧子显自己写的】,不是我们读出来的。
assert.ok(
/故子响怒,遣兵破尹略军/.test(cites),
'★★ 一个舍人不肯过一趟江 —— 【故】一个亲王怒了,一支军队破了。这个「故」是原文的'
);
// ★★ 而下场落在别人头上:他「被责」,然后「亲任如旧」。
assert.ok(/亲任如旧/.test(JSON.stringify(d.options.find((o) => o.id === 'bu-ken-wang').fate)));
assert.equal(d.options.find((o) => o.id === 'bu-ken-wang').fate.score, 5, '★ 他善终 —— 后果不落在他身上');
// ★★★ 而本纪把这件事写成了另外两个字:「有罪」。
assert.ok(
/巴东王子响有罪,遣丹阳尹萧顺之率军讨之,子响伏诛/.test(cites),
'★ 同一件事,本纪十九个字,一个茹法亮都没有 —— 「有罪」二字底下压着一个舍人的疑畏'
);
});
// ══════════════════════════════════════════════════════════════════════════
// ★★★★ 第六刀:「在场」—— 老板那句「不能救」
//
// 「历史上绝大多数人,在绝大多数大事件里,就是在场而已。而史书会记这一笔。」
// 「⚠ 它不是关口(没有选项,你确实做不了什么)—— 别把它做成一道假题。」
// ══════════════════════════════════════════════════════════════════════════
test('★★★★ 在场【不是关口】—— 判别式:有选项的是关口,没选项的是在场', () => {
assert.deepEqual(assertZaichang(), [], '在场表必须合它自己的全部不变量');
// ★★ 这一条是老板逐字点名的红线:「别把它做成一道假题」。
for (const z of ZAICHANG) {
assert.ok(!('options' in z), `${z.id}: 在场【不许有 options】—— 有选项的就是关口了`);
assert.equal(typeof z.sentence, 'function', `${z.id}: 它是【传里的一句话】`);
assert.ok(z.buNengJiu, `${z.id}: 「不能救」那一句判词不许省 —— 老板说它比「你选了什么」都重`);
}
// ★★★ 它【绝不许】混进 DECISIONS —— 混进去 availableDecisions 就会把它当题发出去。
const dIds = new Set(DECISIONS.map((d) => d.id));
for (const z of ZAICHANG) {
assert.ok(!dIds.has(z.id), `${z.id}: 在场混进了 DECISIONS —— 那就成了一道【没有选项的题】`);
}
// ★ 而守卫必须真的会响:种一个「长了 options 的在场」进去,assertZaichang 必须报。
const fake = {
id: 'zc-fake', dynasty: 'nanchao', seq: 1, sentence: () => '某在朝。',
buNengJiu: 'x', source: 's', quote: 'q', caveat: '拟托',
options: [{ id: 'a' }], // ← 假题
};
const found = assertZaichang([fake]).filter((s) => s.includes('不许有 options'));
assert.equal(found.length, 1, '★ 一个长了选项的在场 ⇒ 必须当场报(守卫自己不灵,等于没有守卫)');
});
test('★★★ 在场·南齐:明帝杀光高武旧人,而你在场 —— 「不见疑」比「不能救」更冷', () => {
const rank = nc();
const L = openLineage({ subjectId: 'u', rank });
const got = zaichangFor(L);
assert.ok(got.length >= 2, '★ 南齐的舍人有在场这几笔');
// ★★ 时序:传是编年体。永明(483–493)必须排在建武(494–498)前面。
// ⚠ 这一条是本刀【真犯过】的错:初稿按数组顺序发,把建武排在了永明前头。
const seqs = got.map((z) => z.seq);
assert.deepEqual(seqs, [...seqs].sort((a, b) => a - b), '★★ 在场必须按时序进传 —— 传倒着走就不是传了');
const gaowu = got.find((z) => z.id === 'zc-nanqi-gaowu-jiuren');
assert.ok(gaowu, '「高武旧人鲜有存者」那一笔应当在');
// ★★★ 原文自带那个「故」字 —— 因果是萧子显下的,不是我们读出来的。
assert.ok(
/高武旧人鲜有存者,法亮以主署文事,故不见疑,位任如故/.test(gaowu.quote),
'★ 原文(《南齐书》卷56,当代史,双证)'
);
assert.ok(/不见疑/.test(gaowu.sentence('某')), '★ 传里那一句');
assert.ok(
/不值得杀|不见疑/.test(gaowu.buNengJiu),
'★★ 「不能救」还假设你想救;「不见疑」连这个假设都不给你'
);
// ★★ 而它【不看链】:你选什么它都发生 —— 这正是它不是关口的意思。
const a = appendDecision(L, D0, 'bu-ken-wang');
const b = appendDecision(L, D0, 'du-jiang');
assert.deepEqual(
zaichangFor(a).map((z) => z.id), zaichangFor(b).map((z) => z.id),
'★★ 在场与你选了什么【无关】—— 够不到的事,你选什么它都发生'
);
// ★ 而位阶够不到那一格的人,没有这一笔(他不在那间屋子里)。
const wj = computeRank(clerk(), 'weijin');
assert.deepEqual(zaichangFor({ dynasty: 'weijin', rank: wj }), [], '不在那一格的人没有这一笔');
});
test('★★★★ 在场·西汉(gongqing):机器在、料在,而【西汉这条路仍发不出来】—— 西汉未落地', () => {
// ══════════════════════════════════════════════════════════════════════
// ★★ 2026-07-17 第八刀:本测试【只管西汉那条路(gongqing.js)】—— 它仍然发不出来。
// ⚠ 而在场·汉整体【不再是「一句都发不出来」】:东汉那条路(tianxia)今天就发得出去 ——
// 见下一条测试「★★★★ 在场翻过来」。判别式没变,变的是【引擎的汉是东汉】,
// 所以走东汉的料(tianxia/donghan)就对得上;走西汉的料(gongqing)还差一步(西汉未落地)。
//
// ★ gongqing.js = 西汉(前156–前87);dynasties.js 的 'han' = 东汉(25–220)。
// 两段不重叠一年 ⇒ TABLE_SCOPE.dynastyIds = [](那是 gongqing.js 自己的结论)。
// ⇒ 硬接上去 = 把晁错塞进一部东汉的传里 = 时代错置。故这条路对任何一朝都答「不认领」。
// ══════════════════════════════════════════════════════════════════════
const got = gongqingZaichangFor('han', -154);
assert.equal(got.claimed, false, '★ 没有一朝认领那张表 —— 这是 gongqing.js 自己写死的结论');
assert.deepEqual(got.entries, [], '★★ 在场·汉 = 0 条。硬接上去 = 把晁错塞进一部东汉的传里');
assert.ok(/西汉|东汉/.test(got.why), '★ 而它必须【说清楚为什么】,不许静默返回空');
// ★★ 而【机器现在就可以验】—— 没上线不等于不验(同 assertInvariants(HAN_DECISIONS))。
const built = zaichangFromEvents(-154);
assert.ok(built, '★ 拿景帝三年跑它:机器是通的');
assert.ok(built.entries.length >= 1, '★ 那一年表里真的有人完了');
const cuo = built.entries.find((e) => /要斬/.test(e.quote));
assert.ok(cuo, '★ 景帝三年那一格:「正月壬子,錯有罪要斬。」');
// ══════════════════════════════════════════════════════════════════════
// ★★★★ 而这是这条测试真正要钉的东西:【老板给的那句样例,自己就是一句拼引文】。
//
// 老板原话:「景帝三年,吴楚反。错以削藩故,斩于东市。某时为中大夫,在朝,不能救。」
// ★ 这十九个字取了【三卷】的料:
// ·「正月壬子,錯有罪要斬」→ 卷十九下·百官公卿表下(= gongqing.js)
// ·「东市」 → 卷四十九·爰盎晁錯傳 ← 另一卷
// ·「吴楚反」 → 卷五·景帝纪 ← 又一卷
// ⇒ gongqing.js 的文件头逐字禁着它:「把传里的细节记到表的账上,
// 正是本项目最忌的那种错。」
// ⇒ ★ 故本刀【不吵,把它做成结构上犯不了的错】:造句只许用那一格自己的 quote。
// ══════════════════════════════════════════════════════════════════════
const s = cuo.sentence('某');
assert.ok(!/东市|東巿|東市/.test(s), '★★ 「东市」不在这一卷(它在卷四十九)—— 不许拼进来');
assert.ok(!/吴楚反|吳楚反/.test(s), '★★ 「吴楚反」不在这一卷(它在卷五·景帝纪)—— 不许拼进来');
assert.ok(!/腰斩|腰斬/.test(s), '★ 表作「要斬」不作「腰斬」(要,通腰)—— 照录原字,不许径改');
assert.ok(/要斬/.test(s), '★ 表的字:要斬');
assert.ok(/某在朝,不与焉/.test(s), '★ 老板要的那一笔:你在场,而你不在里面');
// ★ 而「某在朝,不与焉」是【我们的话】,不是表的字 —— 注里必须说出来。
assert.ok(/句号之前是表的字/.test(cuo.buNengJiu), '★ 那条界线必须写给用户看');
// ══════════════════════════════════════════════════════════════════════
// ★★★★ 空 ≠ 没事 —— 而这条断言【真的抓到了一个洞】,值得逐字留着:
//
// 景帝四年(前153):表【记了一笔】(「御史大夫介。」,事件是「拜」),
// 但【没有人完】⇒ 本函数的 entries 是空的。
// ★ 而 gongqing.js 的 ev.emptyNote 只在【一笔都没记】时才给 ⇒ 它是空串
// ⇒ ⇒ 初版于是吐出【空 entries + 一句话都没有】:读起来就是「这一年没人出事」。
// ★★ 正确的读法是【表没记有人出事】—— 表只记三公栏的变动,
// 九卿只抠了两处,列将军表自己就阙。一个人死了而他不在那几个位子上,表一个字都没有。
// ⇒ 判别式:【谁裁的空,谁解释那个空】。terminal 这一刀是 zaichang 切的,就得 zaichang 交代。
// ══════════════════════════════════════════════════════════════════════
const quiet = zaichangFromEvents(-153);
assert.ok(quiet, '景帝四年也在覆盖范围内');
assert.deepEqual(quiet.entries, [], '景帝四年表里没有人完');
assert.ok(
quiet.emptyNote && quiet.emptyNote.trim(),
'★★★ entries 空的时候【必须有一句话】—— 空的 entries 配空的 note,' +
'前端读到的就是「这一年没人出事」,而正确的读法是「表没记有人出事」'
);
assert.ok(/不是结论|没记/.test(quiet.emptyNote), '★★ 空【不是结论】这句话必须说出来');
});
test('★★★★ 在场翻过来:位阶低的人【拿到在场】,三公(tier5)拿到的是关口 —— 东汉今天就发', async () => {
// ══════════════════════════════════════════════════════════════════════
// ★★★ 老板逐字:「位阶【低】的人【更应该】拿到在场,而位阶高的人拿到的是【关口】——
// 他能做点什么。这才是「位阶决定你拿到哪一种」。」
//
// 【曾经反了】在场只有两条南齐笔,requiresRank 都是 sheren/minTier:4 ——
// 即在场只发给那个拿到全部三道题的最高一格,够不到的人一笔都没有。用途写反了。
// ⇒ 第八刀翻过来:在场·东汉发给【tier0–4】,三公(tier5)拿不到在场,他拿谏争那道关口。
// ══════════════════════════════════════════════════════════════════════
// ① 料源:从 tianxia 取,不是硬编 gongqing。东汉的大事今天就在。
const t166 = zaichangFromTianxia('donghan', 166);
assert.equal(t166.inSpan, true, '★ 东汉 166 年在跨度内 —— 引擎的汉是东汉,对得上');
const dg = t166.events.find((e) => /李膺/.test(e.shi));
assert.ok(dg, '★ 第一次党锢(166)的原文应当在 tianxia 里');
assert.match(dg.shi, /受誣爲黨人/, '★ 原文(《后汉书》本纪,单卷双证)');
assert.deepEqual(zaichangFromTianxia('nanqi', 490).events, [], '★ 别的朝今天没有大事料 —— 照实说,不装');
assert.match(zaichangFromTianxia('nope', 1).why, /没有/, '★ 未知朝必须说清楚,不许静默返回空');
// ② 在场·东汉进了 ZAICHANG,且位阶【翻过来】:发给 tier0–4,三公(tier5)封在外。
const han = ZAICHANG.filter((z) => z.dynasty === 'han');
assert.ok(han.length >= 3, '★ 桓灵之际那一簇在场·东汉应当在(党锢/矫诏/钩党)');
for (const z of han) {
assert.ok(!('options' in z), `${z.id}: 在场不许有 options —— 它不是关口`);
assert.equal(z.requiresRank.dynasty, 'han');
assert.equal(z.requiresRank.minTier, 0, `${z.id}: ★ 低到 tier0(乡佐/斗食也在场)`);
assert.equal(z.requiresRank.maxTier, 4, `${z.id}: ★★ 封顶 tier4 —— 三公(tier5)拿不到在场`);
}
// ③ ★★ 翻过来的证据:低位阶【拿到】在场,三公【拿不到】。
const t0 = { dynasty: 'han', rank: { dynasty: { id: 'han' }, channel: null, rank: { tier: 0 } } };
const t3 = { dynasty: 'han', rank: { dynasty: { id: 'han' }, channel: { id: 'bizhao' }, rank: { tier: 3 } } };
const t5 = { dynasty: 'han', rank: { dynasty: { id: 'han' }, channel: { id: 'xiaolian' }, rank: { tier: 5 } } };
assert.ok(zaichangFor(t0).length >= 3, '★★ 斗食小吏(tier0,无通道)拿到在场 —— 这是「翻过来」的核心');
assert.ok(zaichangFor(t3).length >= 3, '★ 县令(tier3)也拿到在场');
assert.deepEqual(zaichangFor(t5).map((z) => z.id), [], '★★ 三公(tier5)拿不到在场 —— 他拿到的是关口');
// ★★★ 而三公【确实】拿到那道关口(谏争)—— 这才是「他能做点什么」。低位阶反而够不到它。
const jian = HAN_DECISIONS.find((d) => d.id === 'han-t5-jian-zheng');
assert.ok(jian, '谏争那道关口应当在');
assert.equal(canReach(t5.rank, jian), true, '★★ 三公拿到的是关口(谏争)—— 这就是「位阶决定你拿到哪一种」');
assert.equal(canReach(t0.rank, jian), false, '★ 斗食小吏够不到谏争那道关口 —— 他拿到的是在场');
// ④ 时序:编年体,seq 升序(同南齐那条守卫)。
const seqs = zaichangFor(t0).map((z) => z.seq);
assert.deepEqual(seqs, [...seqs].sort((a, b) => a - b), '★ 在场按 seq 升序进传 —— 传倒着走就不是传了');
// ⑤ ★★★ 不许拼引文:造句只用那一格自己的原文(同 gongqing 那条断言,做成结构上犯不了)。
for (const z of han) {
const s = z.sentence('某');
assert.ok(s.startsWith(z.quote), `${z.id}: ★ 句号之前必须是本卷原文(quote),一个字都不许从别卷来`);
for (const w of ['梁冀', '竇憲', '熹平', '西邸', '石經', '度田']) {
if (!z.quote.includes(w)) {
assert.ok(!s.includes(w), `${z.id}: ★★ 「${w}」不在这一格的原文里 —— 不许拼进来`);
}
}
for (const m of ['★', '⚠', '✗', '⇒']) {
assert.ok(!s.includes(m), `${z.id}: 正文不许出现注释标记「${m}」`);
}
}
// ⑥ ★★★★ 落地:一个低位阶东汉人的传,从前空(在场笔=0),现在有在场了。
const { buildZhuan } = await import('../src/zhuan.js');
const rankT0 = computeRank(profileFromSubs(['poetic'], { wang: 0 }), 'han');
assert.equal(rankT0.rank.tier, 0, '前提:这个画像在东汉落在 tier0(斗食小吏)');
// 空链(够不到任何关口的极端情形,等价于老板给的那张「无一事及之」样张)。
const Lempty = { ...openLineage({ subjectId: 'z', rank: rankT0 }), closed: true };
const z = buildZhuan(Lempty, { person: { name: '某' } });
assert.deepEqual(z.violations, [], `★ 空链东汉传体例必须全绿:${JSON.stringify(z.violations)}`);
const zc = z.zhu.filter((zh) => zh.what.includes('在场'));
assert.ok(zc.length >= 3, '★★★ 从前「在场笔=0」,现在低位阶的人有在场了 —— 老板点名的那个空补上了');
// ★★ 顺序(第八刀修的那个反了的顺序,文件头★五之③):
// 「终某之世,无一事及之」必须在在场【之前】——先说没关口,再说而你在场,不许反。
const idxWu = z.zhengwenText.indexOf('无一事及之');
const idxZc = z.zhengwenText.indexOf(zc[0].quote);
assert.ok(idxWu >= 0 && idxZc >= 0, '两句都应当在正文里');
assert.ok(idxWu < idxZc, '★★ 「无一事及之」在在场【之前】—— 曾经反了(在场在前),读成自相矛盾,已修');
});
test('★★★ 关口·汉:没上线,不等于不验', () => {
// ★★ HAN_DECISIONS 【故意不在 DECISIONS 里】(缺《后汉书》体例的断语库,摊进去就是八个 500,
// 见 decisions.js 末尾那段长注)。⇒ 而「不在 DECISIONS 里」意味着 assertInvariants()
// 走不到它 —— 那它就成了一堆没人验的数据,正是 penaltyIfNoWang 当年躺着的那种地方。
// ⇒ 所以这条测试拿【那份 list】跑一遍不变量。
// ★ 判别式:一份数据【将来会被谁读】决定它现在要不要被验,不是【它现在有没有被读】。
assert.deepEqual(assertInvariants(HAN_DECISIONS), [], '汉那八道题必须合本表的全部不变量');
assert.equal(HAN_DECISIONS.filter((d) => !d.lite).length, 8, '六格八道【正式】题(tier4 与 tier5 各两道)—— lite 解耦题(如晁错削藩)另计、免论赞税');
// ★ 每一格都要有题 —— 老板点名:「六格,每格至少一道题。⚠ tier0 也要有」。
const tiers = new Set(HAN_DECISIONS.map((d) => d.requiresRank.minTier));
assert.deepEqual([...tiers].sort(), [0, 1, 2, 3, 4, 5], '★ 六格一格不缺,tier0 也在');
// ★★ 上限也要有 —— 老板:「御史大夫不会去判一桩县里的疑狱」。
for (const d of HAN_DECISIONS) {
if (d.lite) continue; // lite 解耦题不配 duty/tuiyan 由头(requiresRank 由 assertInvariants 覆盖)
assert.equal(
typeof d.requiresRank.maxTier, 'number',
`${d.id}: 每一格都必须有上限 —— 位阶不够碰不到,位阶太高也不该拿到`
);
// ★ 由头必须挂着 duty 原文,且原文与推演必须分开标(老板点名的那一条)。
assert.equal(d.duty?.kind, 'yuanwen', `${d.id}: 由头必须挂 duty 原文(kind=yuanwen)`);
assert.ok(d.duty.quote && d.duty.source, `${d.id}: duty 必须有原文和卷次`);
assert.equal(d.duty.witnesses?.length, 2, `${d.id}: duty 必须有两个独立见证`);
assert.equal(d.tuiyan?.kind, 'tuiyan', `${d.id}: 「他会面对什么」必须显式标成推演`);
// ★★ 推演【不许安卷次】—— 这是本产品最贵的那条红线。
assert.ok(!d.tuiyan.source, `${d.id}: 推演不许有 source —— 一句推演沾上卷次就变得不可反驳`);
assert.ok(!d.tuiyan.quote, `${d.id}: 推演不许有 quote`);
}
});
test('★★★ 关口·魏晋:体例做完(史臣曰·散句判词)、摊进 DECISIONS —— 从「不摊」翻案', () => {
// ★ 2026-07-17 论赞刀:魏晋的《晋书》体例(shizan.js TIYUAN 的 zanMode='san' 散句判词
// + zhuan.js SAN_ZHUAN_BANK)做完了 ⇒ 判别式【体例齐了才发得出去】现在对魏晋成立,摊进 DECISIONS。
assert.deepEqual(assertInvariants(WEIJIN_DECISIONS), [], '魏晋三道题必须合本表的全部不变量');
assert.equal(WEIJIN_DECISIONS.length, 3, '魏晋三道题(tier0 争乡品 / tier4 口不论世事 / tier5 定品)');
// ★ 覆盖到顶到底:tier0 / tier4 / tier5(中间三格职官志不给地方官职掌,honestly NOT_DONE)。
const tiers = WEIJIN_DECISIONS.map((d) => d.requiresRank.minTier).sort();
assert.deepEqual(tiers, [0, 4, 5], '★ 覆盖 tier0/4/5 —— 中间三格无职掌正文,NOT_DONE');
for (const d of WEIJIN_DECISIONS) {
assert.equal(d.requiresRank.dynasty, 'weijin');
assert.equal(typeof d.requiresRank.maxTier, 'number', `${d.id}: 上限也要有`);
assert.equal(d.requiresRank.minTier, d.requiresRank.maxTier, `${d.id}: 一格一道,min===max`);
for (const o of d.options) {
assert.ok(o.persona, `${d.id}/${o.id}: persona 不许空(史料画像,本刀已逐条 curl 卷次核过)`);
assert.equal(o.layer, 'shi', `${d.id}/${o.id}: 魏晋三格的题都是「事」层,无制可改`);
}
}
// ★★ tier0 那道题【单轨 + orNoChannel + 签字】—— 魏晋三途共一条 ladder(dynasties.js 单轨)。
const t0 = WEIJIN_DECISIONS.find((d) => d.requiresRank.minTier === 0);
assert.equal(t0.requiresRank.orNoChannel, true, '★ 不入流无品:没有通道,须 orNoChannel');
assert.ok(t0.requiresRankNote, '★ orNoChannel 必须签字说明凭什么断魏晋是单轨');
assert.equal(t0.historical, null, '★ 被中正定品的寒门史无载');
assert.match(t0.historicalNote, /史无载|无载|沉默|没有名字/, '★ 「史无载」是结论,须签字');
// ★★★ 中正在《晋书·职官志》零命中 ⇒ tier0/t5 的由头【不是职掌正文】,是政论+实例(usable=false)。
assert.equal(t0.zhenglun?.kind, 'zhenglun', '★ tier0 由头挂政论(刘毅八损疏),不是职掌正文');
assert.equal(t0.zhenglun.usableAsInstitution, false, '★ 政论证「当时人如此认识」,✗ 不证制度运作');
// ★ 而 tier4(清谈)是三道里唯一有职掌正文的:散骑常侍「同掌規諫,不典事」(职官志卷24,双证)。
const t4 = WEIJIN_DECISIONS.find((d) => d.requiresRank.minTier === 4);
assert.equal(t4.duty?.kind, 'yuanwen', '★ tier4 有职掌正文(散骑常侍)');
assert.equal(t4.duty.witnesses?.length, 2, '★ 职掌正文必须双证');
// ★★ 它【确实在 DECISIONS 里了】—— 体例已齐(从「故意不在」翻案)。
for (const d of WEIJIN_DECISIONS) {
assert.ok(DECISIONS.includes(d), `${d.id}: 魏晋体例已齐 ⇒ 摊进了 DECISIONS`);
}
// ★ tiyuanFor('weijin') 不再抛 —— 它有了体例(史臣曰·散句判词,zanMode='san')。
const ty = tiyuanFor('weijin');
assert.equal(ty.faYuCi, '史臣曰', '★ 魏晋发语词以 dizan.js getDizan("xijin").tiyuan 为准:史臣曰(晋书)');
assert.equal(ty.zanMode, 'san', '★ 散句判词:晋书史臣曰是散体史论,不缀四言韵赞 —— 宁可散不要假韵');
// ⇒ 于是那个不入流的魏晋人,listDecisions 拿到了他自己那道 tier0 题(orNoChannel)。
const wj = computeRank(clerk(), 'weijin');
assert.deepEqual(
listDecisions(wj).map((d) => d.id), ['weijin-t0-zheng-pin'],
'★ 体例齐、摊进去 ⇒ 不入流的魏晋人拿到他自己那道 tier0 题(争乡品)'
);
});
// ══════════════════════════════════════════════════════════════════════════
// ★★★ 2026-07-17 论赞刀(关口·唐/宋/明/辽)—— 与 weijin 那条【逐字同构】:
// 体例做完(shizan.js TIYUAN 的 zanMode='san' 散句判词 + zhuan.js SAN_ZHUAN_BANK)、摊进 DECISIONS。
//
// ★ 判别式与 han 第七刀、魏晋同一条:「体例齐了才发得出去」。这四朝的体例现在都有了:
// · tiyuanFor('tang'|'song'|'ming'|'liao') 不再抛,返回体例(发语词=赞曰,zanMode=san);
// · 摊进去后,答完题出传【不再 500】—— buildZhuan / shizanForLineage 走散句判词那条路。
// 发语词以 dizan.js getDizan(code).tiyuan 为准(唐/宋/辽 双证=贊曰;明《明史》体例同类=贊曰)。
// ══════════════════════════════════════════════════════════════════════════
const HELD = {
tang: { list: TANG_DECISIONS, count: 5, tiers: [1, 1, 2, 3, 5], shu: '新唐书' },
song: { list: SONG_DECISIONS, count: 5, tiers: [1, 2, 3, 4, 5], shu: '宋史' },
ming: { list: MING_DECISIONS, count: 4, tiers: [1, 2, 3, 5], shu: '明史' },
liao: { list: LIAO_DECISIONS, count: 3, tiers: [1, 4, 5], shu: '辽史' },
};
for (const [dyn, spec] of Object.entries(HELD)) {
test(`★★★ 关口·${dyn}:体例做完(赞曰·散句判词)、摊进 DECISIONS —— 从「不摊」翻案`, () => {
const { list, count, tiers } = spec;
// ① 结构:合本表的全部不变量。
assert.deepEqual(assertInvariants(list), [], `${dyn} 那几道题必须合本表的全部不变量`);
assert.equal(list.length, count, `${dyn}:${count} 道题`);
// ② 逐 tier 有题:min===max,一格一道;上限、dynasty、persona 都得在。
const gotTiers = list.map((d) => d.requiresRank.minTier).sort((a, b) => a - b);
assert.deepEqual(gotTiers, [...tiers].sort((a, b) => a - b), `${dyn}:覆盖的 tier 对得上梯子`);
for (const d of list) {
assert.equal(d.requiresRank.dynasty, dyn, `${d.id}: requiresRank.dynasty 必须是 ${dyn}`);
assert.equal(typeof d.requiresRank.maxTier, 'number', `${d.id}: 上限也要有`);
assert.equal(d.requiresRank.minTier, d.requiresRank.maxTier, `${d.id}: 一格一道,min===max`);
assert.ok(d.options.length > 0, `${d.id}: 得有 option`);
for (const o of d.options) {
assert.ok(o.persona, `${d.id}/${o.id}: persona 不许空(史料画像,已逐条 curl 卷次双证核过)`);
}
}
// ③ ★★ 体例已齐的判别式(从「不在 DECISIONS 里」翻案):它【确实在 DECISIONS 里了】。
for (const d of list) {
assert.ok(DECISIONS.includes(d), `${d.id}: ${dyn} 体例已齐 ⇒ 摊进了 DECISIONS`);
}
// ④ ★★★ 把「为什么摊得进」钉成断言:tiyuanFor(dyn) 不再抛,且是散句判词体例(赞曰)。
const ty = tiyuanFor(dyn);
assert.equal(ty.faYuCi, '赞曰', `★ ${dyn} 发语词=赞曰(唐/宋/辽 双证;明《明史》体例同类)`);
assert.equal(ty.zanMode, 'san', `★ ${dyn} 散句判词:官修诸史散体史论,不缀四言韵赞 —— 宁可散不要假韵`);
assert.equal(ty.rhyme, null, `★ ${dyn} 无韵部(散句,不查韵)`);
});
}
test('★★★ 论赞总账:DECISIONS = 32(汉8+制度级1+南齐3+唐5+宋5+明4+魏晋3+辽3),全部上线', () => {
// ★ 论赞刀的最终状态:唐宋明魏晋辽,五朝【20 道题全摊进 DECISIONS】—— 体例(散句判词)都做完了。
// ★ 2026-07-20 回响B:制度级关口「察举加试」(dynasty:han, sanZan 散句判词) 上线 ⇒ 汉侧 8→9,总 31→32。
assert.equal(DECISIONS.filter((d) => !d.lite).length, 32, '★ 正式题(配全论赞体例): 汉8 + 制度级1 + 南齐3 + 唐5 + 宋5 + 明4 + 魏晋3 + 辽3 = 32');
assert.ok(DECISIONS.some((d) => d.id === 'han-t5-xue-fan' && d.lite), '★ lite 解耦题(免论赞税、成传走回落): 晁错削藩已接入');
const byDyn = {};
for (const d of DECISIONS) if (!d.lite) byDyn[d.dynasty] = (byDyn[d.dynasty] || 0) + 1; // lite 解耦题另计
assert.deepEqual(
byDyn, { nanchao: 3, han: 9, tang: 5, song: 5, ming: 4, weijin: 3, liao: 3 },
'★ 七朝 32 道题都在 DECISIONS 里(汉含制度级察举加试)'
);
// ★ 五朝的 *_DECISIONS 每一道都进了 DECISIONS(20 道,一道不落)。
const held = { tang: TANG_DECISIONS, song: SONG_DECISIONS, ming: MING_DECISIONS, liao: LIAO_DECISIONS, weijin: WEIJIN_DECISIONS };
const ids = new Set(DECISIONS.map((d) => d.id));
let n = 0;
for (const [dyn, list] of Object.entries(held)) {
assert.ok(Array.isArray(list) && list.length > 0, `${dyn}_DECISIONS 已导出`);
assert.ok(list.every((d) => ids.has(d.id)), `${dyn} 每一道都摊进了 DECISIONS`);
n += list.length;
}
assert.equal(n, 20, '★ 五朝共 20 道题(唐5+宋5+明4+魏晋3+辽3)');
});
// ══════════════════════════════════════════════════════════════════════════
// ★★★★ 2026-07-17 论赞刀:五朝【逐链出传】—— 判别式「体例齐了才发得出去」的可执行形式。
//
// ★ 老板要的验收逐字:「构造那朝一道题的闭合 lineage 跑 buildZhuan,确认不抛」。
// 而「测了一条链 ≠ 测了这个函数」(lineage.test.js 那条判别式)—— 故这里【穷举】每朝每格
// 每个 option 组合(含唐 tier1 校书郎/县尉同格两官的两步链、两种次序),逐条出传验体例。
// ⇒ 五朝任何一道题答完抛 500 / 违体例,这条当场红。
// ⚠ 纯函数,不吃 DB。用手搭的 rank(同 lineage.test.js 的 sweep)——computeRank 未必把样板人
// 放到每一格,故直接按 requiresRank 搭一个够得到那一格的 rank。
// ══════════════════════════════════════════════════════════════════════════
test('★★★★ 论赞·五朝逐链出传:每格每 option 都不抛、体例全绿、散句判词(无四言赞、无韵)', () => {
const groups = {
tang: TANG_DECISIONS, song: SONG_DECISIONS, ming: MING_DECISIONS,
weijin: WEIJIN_DECISIONS, liao: LIAO_DECISIONS,
};
const chanFor = (rq) => rq.channelId || (rq.channelIds && rq.channelIds[0]) || null;
const walk = (L, acc) => {
if (L.closed) { acc.push(L); return acc; }
for (const d of availableDecisions(stateOf(L))) {
for (const o of d.options) acc = walk(appendDecision(L, d.id, o.id), acc);
}
return acc;
};
let built = 0;
for (const [dyn, list] of Object.entries(groups)) {
const tiers = [...new Set(list.map((d) => d.requiresRank.minTier))].sort((a, b) => a - b);
const fy = dyn === 'weijin' ? '史臣曰' : '赞曰'; // dizan.js:晋书=史臣曰,唐宋明辽=赞曰
for (const tier of tiers) {
const anyD = list.find((d) => d.requiresRank.minTier === tier);
const chId = chanFor(anyD.requiresRank);
const rank = {
dynasty: { id: dyn, name: dyn, era: dyn, span: '' },
channel: chId ? { id: chId } : null,
rank: { title: '某官', grade: '某品', unit: 'x', tier },
};
const all = walk(openLineage({ subjectId: 'zan', rank }), []);
const stepped = all.filter((L) => L.chain.length > 0);
assert.ok(stepped.length > 0, `★ ${dyn} tier${tier} 应有走得通的链(channel=${chId})`);
for (const L of stepped) {
const path = L.chain.map((s) => `${s.decisionId}/${s.optionId}`).join(' → ');
const z = buildZhuan(L, { person: { name: '某' } });
assert.ok(z, `${dyn} [${path}]:成传返回 null`);
built += 1;
// ★ 正文体例全绿 + 断语体例全绿(发语词唯一、无别一部书的发语词)。
assert.deepEqual(z.violations, [], `${dyn} [${path}] 正文体例违规:${JSON.stringify(z.violations)}`);
assert.ok(z.shizan, `${dyn} [${path}]:链非空却没有传末断语`);
assert.deepEqual(z.shizan.violations, [], `${dyn} [${path}] 断语体例违规:${JSON.stringify(z.shizan.violations)}`);
// ★★ 散句判词:无四言赞、无韵。
assert.deepEqual(z.shizan.zan, [], `${dyn} [${path}]:散句判词朝不该有四言赞`);
assert.equal(z.shizan.rhyme, null, `${dyn} [${path}]:散句朝无韵部`);
// ★★★ 发语词【正好一次】,且【没有别一部书的发语词】。
assert.equal(
z.shizan.shichen.split(fy).length - 1, 1,
`${dyn} [${path}]:发语词「${fy}」必须正好出现一次`
);
for (const other of ['史臣曰', '论曰', '赞曰']) {
if (other === fy) continue;
assert.ok(
!z.shizan.shichen.includes(other),
`${dyn} [${path}]:断语里混进了别一部书的发语词「${other}」`
);
}
// ★ 推演绝不许带卷次(逐条注再钉一遍)。
for (const zh of z.zhu) {
assert.ok(zh.caveat && zh.caveat.trim(), `${dyn} [${path}] 注〔${zh.n}〕缺 caveat`);
if (zh.kind === 'tuiyan') assert.ok(!zh.source, `${dyn} [${path}] 注〔${zh.n}〕推演却安了卷次`);
}
}
}
}
assert.ok(built >= 60, `只出了 ${built} 部传 —— 太少,说明穷举没跑对`);
});
test('★★★ 关口·汉:tier0 那道题,必须对 tier0 的人开着(这一条是真踩过的坑)', () => {
// ★★ 本刀第一版把它写死了:tier0 的人【没有通道】(一条闸都没过,那正是他在 tier0 的原因),
// 而 requiresRank 拿 channelIds 问他「你走的是哪条路」——
// ⇒ 【拿一个人缺的东西当他的准入条件】⇒ 斗食小吏拿不到斗食小吏那道题。
// ⚠ 而它【一条测试都不会红】:那道题静静地对着它唯一的读者关上。所以现在有这一条。
const t0 = { dynasty: { id: 'han' }, channel: null, rank: { tier: 0, title: '郡县小吏' } };
const q0 = HAN_DECISIONS.find((d) => d.requiresRank.minTier === 0);
assert.ok(canReach(t0, q0), '★★ 斗食小吏必须拿得到他自己那道题 —— 老板点名:「⚠ tier0 也要有」');
// ⚠ 而 orNoChannel 【只在单轨朝代成立】,✗ 不许漏给多轨朝代的「白身」:
// 多轨的 tier0 是「不在任何一条路上」,替他挑一条梯子正是 dynasties.js 逐字禁的。
const ncBai = { dynasty: { id: 'nanchao' }, channel: null, rank: { tier: 0, title: '白身' } };
assert.equal(canReach(ncBai, q0), false, '南齐的白身拿不到汉的题(朝代先挡住)');
for (const d of HAN_DECISIONS) {
if (d.requiresRank.orNoChannel) {
assert.equal(d.requiresRank.minTier, 0, `${d.id}: orNoChannel 只许用在 tier0 —— 别的格都有通道`);
assert.ok(d.requiresRankNote, `${d.id}: orNoChannel 必须签字说明凭什么断这一朝是单轨`);
}
}
// ★ 反向:县令【不许】拿到斗食小吏那道题(上限)。
const t3 = { dynasty: { id: 'han' }, channel: { id: 'bizhao' }, rank: { tier: 3 } };
assert.equal(canReach(t3, q0), false, '★ 县令拿不到乡佐那道题 —— 去敲那扇门的人不是他');
});
test('★★★ 关口·汉:史无载就是 null,✗ 不许编一个 historical', () => {
// ★★★ 这一条是这一刀的产品结论本身(老板原话:「九成的选择落在「事」层,五十年内被抹平
// —— 但【你选了】。而你的传里写着你选了什么。」):
// 八道题里【七道没有 historical】—— 不是没查,是【那一格上没有名字】。
// ⇒ 《续汉书·百官志五》白纸黑字写着「郡口二十萬舉一人」:东汉一百多个郡国、近两百年,
// 那是【上万次】有人做了这个选择 —— 而【一次都没被记下来】。
const withHist = HAN_DECISIONS.filter((d) => !d.lite && d.historical !== null);
assert.equal(withHist.length, 1, '★ 八道【正式】题里只有一道有历史答案(三公谏争那一道)—— lite 解耦题另计');
assert.equal(withHist[0].id, 'han-t5-jian-zheng');
// ★ 而它的 historical 是【多数派】,依据是原文的那一个「獨」字。
assert.equal(withHist[0].historical, 'wei-ling', '★ 原文:「固【獨】與杜喬堅守本議」—— 独,即其余皆非');
// ★★ historical=null 【必须签字】:说得出你凭什么知道是「史无载」而不是「我没查」。
for (const d of HAN_DECISIONS) {
if (d.historical === null) {
assert.ok(d.historicalNote, `${d.id}: 史无载是【结论】,必须说清楚凭什么这么断`);
assert.match(
d.historicalNote, /史无载|无载|没有名字|沉默/,
`${d.id}: historicalNote 必须明说这是「沉默」,不是缺值`
);
assert.deepEqual(d.historicalCites, [], `${d.id}: 没有历史答案 ⇒ 不许挂历史答案的史料`);
}
}
// ⚠ 而【空的 historicalNote 必须报】—— 守卫自己不灵,等于没有守卫。
const bad = {
id: 'x', dynasty: 'han', historical: null, // 没有 historicalNote
requiresRank: { dynasty: 'han', channelId: 'bizhao', minTier: 3 }, options: [],
};
assert.ok(
assertInvariants([bad]).some((s) => s.includes('historicalNote')),
'★ historical=null 却不签字 ⇒ 必须报:「史无载」是结论,「没查」不是'
);
});
test('★ 跨通道的 tier 不可比 —— 判别式:只有自己跟自己可比', () => {
// 原文依据(chiduo.js CONFLICT #9,《新唐书》卷44 双证):每一科自带自己的分母 ——
// 进士数「策五道、帖一大经」,明法数「律七条、令三条」。★ 进士的甲第与明法的甲第不可比。
assert.equal(comparableTier('nanchao', 'shizu', 'sheren'), false, '士族的位阶与舍人的位阶不可比');
assert.equal(comparableTier('tang', 'jinshi', 'fanzhen'), false, '唐的每一科自带自己的分母');
assert.equal(comparableTier('han', 'xiaolian', 'bizhao'), false, '★ 同一条梯子上的两条通道,一样不可比');
assert.equal(comparableTier('han', 'xiaolian', 'xiaolian'), true, '自己跟自己 —— 唯一为真的情形');
// ⚠ 失效方向朝关:问错名字答「不」。
assert.equal(comparableTier('han', 'xiaolian', 'TYPO'), false);
assert.equal(comparableTier('nope', 'a', 'a'), false);
assert.equal(comparableTier('han', undefined, undefined), false);
});
test('canReach 三个条件缺一不可', () => {
const d = getDecision(D);
const nc = computeRank(clerk(), 'nanchao');
assert.equal(canReach(nc, d), true);
assert.equal(canReach(null, d), false);
assert.equal(canReach(nc, null), false);
// 朝代不对
assert.equal(canReach({ ...nc, dynasty: { id: 'tang' } }, d), false);
// 通道不对
assert.equal(canReach({ ...nc, channel: { id: 'shizu' } }, d), false);
// 位阶不够
assert.equal(canReach({ ...nc, rank: { ...nc.rank, tier: 3 } }, d), false);
});
// ── 2. 确定性:这是整个功能的信誉基础 ──
test('确定性:同一选择跑两次,结果必须完全相同', () => {
for (const d of DECISIONS) {
for (const o of d.options) {
const a = computeImpact(d.id, o.id);
const b = computeImpact(d.id, o.id);
assert.deepEqual(a, b, `${d.id}/${o.id} 两次不一致 —— 用户跑两次就发现是瞎编的`);
}
}
});
test('史臣曰也必须确定性 —— 它是断语,不是 LLM 生成', () => {
const a = shizanYue({ decisionId: D, optionId: 'zuo-shi', person: { name: '某' } });
const b = shizanYue({ decisionId: D, optionId: 'zuo-shi', person: { name: '某' } });
assert.deepEqual(a, b);
});
test('computeImpact 不接受未知 id', () => {
assert.equal(computeImpact('nope', 'shou-hu'), null);
assert.equal(computeImpact(D, 'nope'), null);
assert.equal(getDecision('nope'), null);
assert.equal(shizanYue({ decisionId: 'nope', optionId: 'shou-hu' }), null);
assert.equal(shizanYue({ decisionId: D, optionId: 'nope' }), null);
});
// ── 3. ★ 授权律(一):改「事」的必被弥合 ──
test('★ 改「事」的选项必须被弥合(years ≤ 50,且实为 0)', () => {
for (const d of DECISIONS) {
for (const o of d.options) {
if (o.layer !== 'shi') continue;
const r = computeImpact(d.id, o.id);
assert.ok(r.years <= 50, `${o.id}: 改「事」却传了 ${r.years} 年`);
assert.equal(r.years, 0, `${o.id}: 改「事」的必被弥合 —— 事随人走,人走事消`);
assert.equal(r.verdict, '没有');
assert.match(r.label, /弥合/);
assert.ok(
r.why.some((s) => s.includes('别人也会做')),
'弥合必须说清楚它为什么是弥合 —— 不是「你失败了」,是「你根本没在场」'
);
}
}
});
test('改「事」的选项,数据里不许标 inherited=true', () => {
for (const d of DECISIONS) {
for (const o of d.options) {
if (o.layer === 'shi') assert.equal(o.inherited.value, false, `${o.id}: 事不可能被继承`);
}
}
});
// ── 4. ★ 授权律(二):改「制」而未被继承的,活不过授权者 ──
test('★ 改「制」且未被继承的,活不过授权者(50 年,一代人)', () => {
const r = computeImpact(D, 'fei-hu'); // 自请废四户:layer=dao,后继者不需要它
assert.notEqual(r.layer, 'shi', '前提:这是个改制/改道的选项');
assert.equal(getDecision(D).options.find((o) => o.id === 'fei-hu').inherited.value, false);
assert.equal(r.years, 50, '未被继承的制,至多活一代人 —— 授权者一死即亡');
assert.equal(r.verdict, '五十年');
assert.ok(
r.why.some((s) => s.includes('后继者') && s.includes('不需要')),
'必须说明它死于「后继者不需要它」,而不是死于「你说得不对」'
);
});
test('★ 改「制」且被继承的,活过授权者(200 年+),而这一票不在你手上', () => {
const r = computeImpact(D, 'zuo-shi'); // 奏请著为令:梁、陈真的坐实了
assert.equal(r.layer, 'zhi');
assert.equal(r.years, 200);
assert.equal(r.verdict, '两百年以上');
assert.ok(
r.why.some((s) => s.includes('不是因为你了不起')),
'必须说明它活下来不是因为你了不起,是因为后继者仍需要它'
);
assert.ok(
r.why.some((s) => s.includes('这一票不在你手上')),
'★ 授权律的核心:制的存亡,你一票都投不了'
);
});
test('制的存亡只由 inherited 决定,与 layer 深浅无关', () => {
// dao 比 zhi 深,但 dao 的那个选项没被继承 → 反而传得短。
// 这一条守着一个很容易犯的错:把「想得深」当成「传得远」。它们是两回事。
const zhi = computeImpact(D, 'zuo-shi');
const dao = computeImpact(D, 'fei-hu');
assert.ok(layerDepth('dao') > layerDepth('zhi'));
assert.ok(dao.years < zhi.years, '想得更深的那个,传得反而更短 —— 因为后继者不需要它');
});
// ── 5. ★★ 反相关:你的下场 vs 你那一念的深度 ──
test('★★ 你个人的下场,和你那一念的深度,反相关', () => {
const bad = assertInvariants();
assert.deepEqual(bad, [], `授权律在数据里被破坏了:\n${bad.join('\n')}`);
});
test('★★ 反相关·具体案例:守户者善终,问「凭什么」者不得其死', () => {
const d = getDecision(D);
const f = (id) => d.options.find((o) => o.id === id).fate.score;
assert.ok(f('shou-hu') > f('zuo-shi'), '守户(事)必须比坐实为令(制)下场好');
assert.ok(f('zuo-shi') > f('fei-hu'), '坐实为令(制)必须比自请废户(道)下场好');
assert.equal(layerDepth('shi') < layerDepth('zhi'), true);
assert.equal(layerDepth('zhi') < layerDepth('dao'), true);
// 而传得最远的那个,恰恰不是下场最好的那个 —— 这就是整个第三刀
const best = ['shou-hu', 'qiu-ming', 'zuo-shi', 'fei-hu']
.map((id) => ({ id, years: computeImpact(D, id).years, fate: f(id) }))
.sort((a, b) => b.years - a.years)[0];
assert.equal(best.id, 'zuo-shi');
assert.ok(best.fate <= 1, '传得最远的那个人,下场必须是坏的 —— 否则这个功能没有立意');
});
test('深选项的 why 里必须把反相关明说出来', () => {
const r = computeImpact(D, 'zuo-shi');
assert.ok(
r.why.some((s) => s.includes('你那一念越深,你自己的下场越坏')),
'这句话是本功能的产品结论,必须出现在结果里,不能只写在注释里'
);
});
// ── 6. ★ 性格锁:只写「你这种人会考虑的选项」,不是灰按钮 ──
test('★ 每个选项必须有 persona —— 没有它就成了灰按钮', () => {
for (const d of DECISIONS) {
for (const o of d.options) {
assert.ok(o.persona && o.persona.length > 10, `${d.id}/${o.id} 缺性格锁`);
}
}
});
test('listDecisions 吐 persona,但不吐 historical / effects / fate', () => {
const nc = computeRank(clerk(), 'nanchao');
const [got] = listDecisions(nc);
for (const o of got.options) {
assert.ok(o.persona, '性格锁必须出给前端 —— 它就是「为什么你只看得见这几个念头」');
assert.equal(o.effects, undefined, '状态量是内部口径');
assert.equal(o.fate, undefined, '下场是选完之后才该给的');
assert.equal(o.inherited, undefined);
}
assert.equal(got.historical, undefined, '当初那个人选了什么,是看完之后才该给的');
});
// ── 7. 历史答案:他实际选了哪个 ──
test('★ 茹法亮实际选的是「守住这一户」,而那一户后来由别人坐实成了定制', () => {
const d = getDecision(D);
assert.equal(d.historical, 'shou-hu');
const r = computeImpact(D, d.historical);
assert.equal(r.years, 0, '他选的那一步被弥合了 —— 他一票都没投,事情照样发生了');
assert.match(d.historicalNote, /梁|陈/, '必须说明是谁在他之后坐实了它');
assert.ok(d.historicalCites.length > 0, '历史答案必须挂史料');
});
// ── 8. 史料:注必须挂得住,且 caveat 必须在 ──
test('每条 cite 必须同时给出处、原文和限定语', () => {
const all = [];
for (const d of DECISIONS) {
all.push(...(d.situationCites || []), ...(d.historicalCites || []));
for (const o of d.options) {
all.push(...(o.cites || []), ...(o.fate?.cites || []), ...(o.inherited?.cites || []));
}
}
assert.ok(all.length >= 8, '样张的史料密度不够');
for (const c of all) {
assert.ok(c.source, `缺 source:${c.what}`);
assert.ok(c.quote, `缺原文:${c.what}`);
// ★ caveat 是注的【价值所在】——注不是为了显得权威,是为了拆穿通俗史观
assert.ok(c.caveat && c.caveat.length > 10, `缺限定语:${c.what}`);
}
});
test('★ 引旁证必须标明朝代 —— 梁、陈、刘宋都不是南齐', () => {
const all = [];
for (const d of DECISIONS) {
for (const o of d.options) {
all.push(...(o.cites || []), ...(o.fate?.cites || []), ...(o.inherited?.cites || []));
}
}
for (const c of all) {
if (/《隋书》/.test(c.source)) {
assert.match(c.caveat, /属梁|属陈|非齐制|不可充齐制/, `${c.what}: 隋书的梁陈制必须标明不是齐制`);
}
if (/《宋书》/.test(c.source)) {
assert.match(c.caveat, /刘宋|非南齐|不可充齐制/, `${c.what}: 宋书是刘宋事,必须标明不是南齐`);
}
}
});
test('★ 回归:查不实的宁可不写 —— 推演的下场不许挂史料', () => {
const d = getDecision(D);
for (const id of ['zuo-shi', 'fei-hu']) {
const o = d.options.find((x) => x.id === id);
// 南齐没人真的这么做过 → 这两格的 fate 是推演,必须自己说明,且【不许安卷次】
assert.match(o.fate.text, /推演,非史事/, `${id}: 推演的下场必须明说它是推演`);
assert.deepEqual(o.fate.cites, [], `${id}: 推演不许挂史料 —— 带卷次的错误比没出处的错误危害大十倍`);
}
});
test('★ 回归:《南齐书》作「殆不关中书」,不是《南史》的「始」', () => {
const d = getDecision(D);
const c = d.situationCites.find((x) => /建武/.test(x.what));
assert.match(c.quote, /诏命殆不关中书/, '南齐书作「殆」(几乎,程度)');
assert.ok(!/诏命始不关中书/.test(c.quote), '「始」是《南史》的字,语义是「开始」,混不得');
assert.match(c.source, /南齐书/);
assert.match(c.caveat, /殆.*始|始.*殆/s, 'caveat 必须交代这处异文');
assert.match(d.situation, /诏命殆不关中书/, '正文引这句也必须作「殆」');
});
test('★ 回归:「四户」不是四个官署,且「直庐」是今人训释不许当引文', () => {
const d = getDecision(D);
const c = d.situationCites.find((x) => /四户.*不是四个官署|内证/.test(x.what));
assert.ok(c, '必须挂「省内舍人四人,所直四省」这条决定性内证');
assert.match(c.quote, /省内舍人四人,所直四省/);
assert.match(c.caveat, /直庐/, 'caveat 必须点破「直庐」二字原文没有');
// 「直庐」不许出现在任何 quote 里
const quotes = [
d.situation,
...d.situationCites.map((x) => x.quote),
...d.options.flatMap((o) => (o.inherited?.cites || []).map((x) => x.quote)),
];
for (const q of quotes) {
assert.ok(!q.includes('直庐'), `「直庐」是今人训释,不许出现在原文/正文里:${q.slice(0, 20)}`);
}
});
test('★ 回归:王俭那两句是南史孤证,且不在《王俭传》', () => {
const d = getDecision(D);
const c = d.situationCites.find((x) => /王俭|收舍人之权/.test(x.what));
assert.match(c.source, /南史/);
assert.match(c.source, /吕文显传/, '在吕文显传,不在王俭传');
assert.match(c.caveat, /孤证/);
assert.match(c.caveat, /不在《王俭传》/);
});
// ── 9. ★ 史臣曰 + 赞曰:体例 ──
test('★★ 《南齐书》体例:史臣曰之后【必有】四言赞曰', () => {
for (const o of getDecision(D).options) {
const s = shizanYue({ decisionId: D, optionId: o.id, person: { name: '某' } });
assert.match(s.shichen, /^史臣曰:/, '发语词必须是「史臣曰」');
assert.ok(s.zan.length > 0, '没有赞就不是《南齐书》的体例 —— 这条被史学核查打回过');
assert.match(s.zanText, /^赞曰:/);
assert.deepEqual(s.violations, [], `${o.id} 的赞不合体例:\n${s.violations.join('\n')}`);
}
});
test('★ 体例说明不许写成「《南齐书》用史臣曰,《后汉书》用论曰+赞曰」的伪对比', () => {
const s = shizanYue({ decisionId: D, optionId: 'shou-hu' });
assert.match(s.tili, /必缀四言韵语「赞曰」|赞例不改/, '必须说清楚南齐书也有赞');
assert.match(s.tili, /摹《后汉书》/, '必须说清楚是摹后汉书之例');
assert.match(s.tili, /只在发语词/, '★ 二者之别只在发语词,不在有没有赞');
});
test('赞:四言、偶句押韵、一韵到底、不许重韵', () => {
for (const o of getDecision(D).options) {
const s = shizanYue({ decisionId: D, optionId: o.id });
assert.equal(s.zan.length % 2, 0);
for (const l of s.zan) assert.equal([...l].length, 4, `「${l}」不是四言`);
assert.deepEqual(validateZan(s.zan, 'zhen'), []);
// 重韵自检
assert.equal(new Set(s.rhyme.feet).size, s.rhyme.feet.length, '一篇之内不许重韵');
}
});
test('validateZan 真的会挡住不合体例的赞', () => {
assert.ok(validateZan(['三个字'], 'zhen').some((x) => x.includes('四言')));
assert.ok(validateZan(['省内四户', '机要所因', '守而不作', '权在其东'], 'zhen')
.some((x) => x.includes('不在')), '不押真部的韵脚必须被挡住');
assert.ok(validateZan(['省内四户', '机要所因', '守而不作', '权在其因'], 'zhen')
.some((x) => x.includes('重韵')));
assert.ok(validateZan([], 'zhen').some((x) => x.includes('必有赞')));
assert.ok(validateZan(['省内四户'], 'zhen').some((x) => x.includes('两句一联')));
});
test('★ 押韵的诚实交代:平水韵是金元韵书,萧子显没见过', () => {
const s = shizanYue({ decisionId: D, optionId: 'shou-hu' });
assert.match(s.rhyme.note, /平水韵/);
assert.match(s.rhyme.note, /金元|13 世纪/);
assert.match(s.rhyme.note, /不是「萧子显会这么押」的断言/);
});
// ── 10. ★★ 断语必须明说自己是断语,不是计算 ──
test('★★ 史臣曰必须明说这是史家的判断,不是计算', () => {
for (const o of getDecision(D).options) {
const s = shizanYue({ decisionId: D, optionId: o.id });
assert.match(s.disclosure, /断语,不是计算/);
assert.match(s.disclosure, /不是 LLM 生成/);
assert.match(s.disclosure, /不是萧子显写的|拟史家之辞/);
assert.match(s.disclosure, /正文是推演,注才是原文/);
// 断语与计算必须对得上 —— 这就是为什么这里不能用 LLM
assert.ok(s.disclosure.includes(s.impact.verdict), '断语里必须带上引擎算出来的那一档');
}
});
test('★ 三档是档位标签,不是测算值 —— 不许伪精确', () => {
assert.deepEqual(IMPACT_TIERS.map((t) => t.years), [0, 50, 200]);
const r = computeImpact(D, 'zuo-shi');
assert.match(r.precisionNote, /档位标签/);
assert.match(r.precisionNote, /伪精确/);
for (const d of DECISIONS) {
for (const o of d.options) {
assert.ok([0, 50, 200].includes(computeImpact(d.id, o.id).years), '只许三档');
}
}
});
test('史臣曰与 computeImpact 的结论必须一致(这就是不用 LLM 的理由)', () => {
for (const o of getDecision(D).options) {
const s = shizanYue({ decisionId: D, optionId: o.id });
const i = computeImpact(D, o.id);
assert.equal(s.impact.years, i.years);
// 赞的「合」联必须与 years 对得上
const he = s.zan.slice(-2).join('');
if (i.years === 0) assert.match(he, /史势所趋/);
if (i.years === 50) assert.match(he, /成尘/);
if (i.years === 200) assert.match(he, /梁陈/);
}
});
// ── 11. 结构守卫 ──
test('LAYERS:事 < 制 < 道,深度严格递增', () => {
const depths = LAYERS.map((l) => l.depth);
for (let i = 1; i < depths.length; i++) assert.ok(depths[i] > depths[i - 1]);
assert.deepEqual(LAYERS.map((l) => l.id), ['shi', 'zhi', 'dao']);
});
test('决策点的 dynasty 必须是 dynasties.js 里真有的朝代', () => {
for (const d of DECISIONS) {
assert.ok(computeRank(clerk(), d.dynasty), `${d.id}: 未知朝代 ${d.dynasty}`);
assert.equal(d.requiresRank.dynasty, d.dynasty, 'requiresRank 的朝代必须与决策点一致');
}
});
test('formatZan:两句一联,一联一行', () => {
const t = formatZan(['省内四户', '机要所因', '守而不作', '权在其身']);
assert.equal(t, '赞曰:\n省内四户,机要所因。\n守而不作,权在其身。');
});
// ══════════════════════════════════════════════════════════════════════════
// 12. ★★★ 结构律★一:不可逆 —— 选了就是选了,没有读档
//
// 这一组是【最要紧的】:多一个 undo,前面三刀(位置/天花板/影响)全部作废——
// 一个能读档的人,他的选择不要钱。
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 不可逆:模块里【没有】任何回退接口,一个都没有', () => {
const names = Object.keys(Dec);
const forbidden = [
'undo', 'revert', 'rollback', 'pop', 'popDecision', 'removeDecision', 'replace',
'setChain', 'restore', 'rewind', 'reset', 'loadSave', 'checkpoint',
];
for (const f of forbidden) {
assert.ok(
!names.some((n) => n.toLowerCase() === f.toLowerCase()),
`decisions.js 导出了「${f}」—— 没有读档。这是结构律★一,不是偏好。`
);
}
for (const n of names) {
assert.ok(
!/undo|revert|rollback|rewind|checkpoint|loadsave|readarchive/i.test(n),
`导出「${n}」看着像读档 —— 游戏才有读档,书没有`
);
}
});
test('★★★ 不可逆:写进去的一步,改不动(深冻结,不靠调用方自觉)', () => {
const L = appendDecision(open1(), D, 'shou-hu');
assert.ok(Object.isFrozen(L), '传本身冻结');
assert.ok(Object.isFrozen(L.chain), '链冻结');
assert.ok(Object.isFrozen(L.chain[0]), '链上每一步都冻结');
assert.throws(() => { L.chain.push({ decisionId: D, optionId: 'qiu-ming' }); }, TypeError);
assert.throws(() => { L.chain[0].optionId = 'qiu-ming'; }, TypeError, '不许把已经写下的那一步改成别的');
assert.throws(() => { L.chain.length = 0; }, TypeError, '不许把链清空');
assert.throws(() => { L.closed = false; }, TypeError, '不许把已成的传掰回去');
});
test('★★★ 不可逆:append 返回【新对象】,旧的那部一个字都没动', () => {
// ★ 第六刀:拿【刚开传那一刻】(open0) 起,这条测试才测得到「空链」这个状态
// —— open1() 现在已经走过一步了(永明那一趟江)。
const L0 = open0();
const L1 = appendDecision(L0, D0, 'bu-ken-wang');
assert.notEqual(L0, L1);
assert.deepEqual(L0.chain, [], '旧的那部仍然是空链 —— 往前写不许改回头的东西');
assert.equal(L1.chain.length, 1);
});
test('★★★ 不可逆:传成了就不能再往里写', () => {
const L = appendDecision(open1(), D, 'qiu-ming'); // 出为大司农 → 关口二不开 → 链走完
assert.equal(L.closed, true, '链走完 → 自动成传');
assert.throws(() => appendDecision(L, D2, 'qi-zuo'), /传已成|不可再书/);
});
test('★★★ 不可逆:同一道题,一部传里只答一次', () => {
const L = appendDecision(open1(), D, 'shou-hu');
assert.ok(!idsOf(L).includes(D), '答过的题不再发第二次');
assert.throws(() => appendDecision(L, D, 'qiu-ming'), /没发到你手上/, '不许换个答案再答一遍');
});
test('★★★ 另写一版:开【新的一部】,旧的那部仍然在、仍然可读 —— 这不是读档', () => {
const L = appendDecision(open1(), D, 'qiu-ming');
assert.equal(L.closed, true);
const L2 = anotherEdition(L, { existing: [L] });
assert.equal(L2.edition, 2, '第二版');
assert.equal(L2.precededBy, L.id, '记得上一版是哪一部 —— 但那是「上一部书」,不是父节点');
assert.notEqual(L2.id, L.id, '另写一版是新的一部,不是覆盖');
assert.deepEqual(L2.chain, [], '★ 新的一版从【空链】起 —— 从中途分叉就是读档留了个备份');
assert.equal(L2.closed, false);
// ★★ 旧的那部仍然在、仍然可读,一个字没动
// ⚠ 第六刀:链长 1 → 2(永明那一趟江 + 建武的求名)。★ 断言的是【旧的那部没被动过】,
// 故改成钉【最后一步】仍是 qiu-ming,而不是钉链长 —— 链长会随着加关口一直变,
// 而「旧的那部没被动过」这件事不会变。
assert.equal(L.chain.length, 2);
assert.equal(L.chain.at(-1).optionId, 'qiu-ming');
assert.equal(L.chain[0].decisionId, D0, '第一步仍是永明八年那一趟江');
assert.equal(L.closed, true);
assert.ok(shizanForLineage(L), '旧的那部仍然读得出传末断语 —— 两部都成立');
});
test('★★★ 另写一版必须先把这一部写完 —— 未完结就另开=读档换了个名字', () => {
const L = appendDecision(open1(), D, 'shou-hu'); // 关口二还开着 → 没写完
assert.equal(L.closed, false);
assert.throws(() => anotherEdition(L), /没有读档|还没写完/);
});
// ══════════════════════════════════════════════════════════════════════════
// 13. ★★ 结构律★二:关口串联 —— 一生是几十个关口,不是一个
// ══════════════════════════════════════════════════════════════════════════
test('★★ 串联:第二个关口的处境,由你【上一个选择】的状态量推出', () => {
// ★ 第六刀:开局手上的是 D0(永明八年那一趟江),过了它才轮到 D(建武的四户)。
assert.deepEqual(idsOf(open0()), [D0], '开局只有入口那道题 —— 而入口现在是永明八年');
const L0 = open1();
assert.deepEqual(idsOf(L0), [D], '过了永明那一趟江,建武那道题才轮得到');
const shou = appendDecision(L0, D, 'shou-hu');
assert.equal(stateOf(shou).values.li, 3, '守住那一户 → 利 +3');
assert.deepEqual(idsOf(shou), [D2], '★ 守住了那一户,第二道题才开 —— 你得先有那个凭借');
assert.equal(shou.closed, false, '还有题没答 → 这部传还没写完');
});
test('★★ 串联:前序选择让某些关口【永久关闭】(权去则恩幸去)', () => {
const qiu = appendDecision(open1(), D, 'qiu-ming'); // 出为大司农:quan −3
assert.ok(stateOf(qiu).values.quan < 0);
assert.deepEqual(idsOf(qiu), [], '权去则恩幸去 —— 连让天子替你开口的凭借都没有了');
assert.equal(qiu.closed, true, '没有关口开着 → 一朝成传');
});
test('★★ 串联:closedByOptions 是【独立】的一道闸 —— 状态量那一关它过得去', () => {
// ★ 这条守着一个真实的坑:奏请著为令的人 quan = +2,state gate(quan ≥ 0)拦不住他。
// 关他的是 closedByOptions:你伸手碰过令文,士林那边的门就对你关死了。
// 两道闸缺一不可 —— 少了哪一道,都会【悄悄多开一个关口】。
const zuo = appendDecision(open1(), D, 'zuo-shi');
assert.ok(stateOf(zuo).values.quan >= 0, '前提:他的 quan 过得了 state 那一关(否则这条测试没有意义)');
assert.deepEqual(idsOf(zuo), [], '你再去乞,人家连床都不必挪');
assert.deepEqual(
preconditionsOf(getDecision(D2)).closedByOptions, [ref(D, 'zuo-shi')],
'这道闸必须用带限定的 ref —— 裸写选项 id 会命中错的人'
);
});
test('★★ 串联:afterDecisions 也是【独立】的一道闸 —— 空链的 quan 也是 0', () => {
// 手搓一个 state:位阶够、状态量够,但一道题都没答过 → 建武末的题不该发给他
const st = { rank: nc(), values: { quan: 0, ming: 0, li: 0, xian: 0 }, taken: [] };
assert.ok(!availableDecisions(st).some((x) => x.id === D2), '没走到建武末的人,拿不到建武末的题');
// ★ 第六刀:同一条闸现在【多挡住一道题】—— 建武的四户也有了 afterDecisions(要求先过永明)。
// ⇒ 一条空链,位阶再够、状态量再满,手上也只有最早的那一道题。
// ★★ 这恰恰把这条测试要证的事证得更硬了:afterDecisions 是【独立】的一道闸,
// 它挡的是【时序】,与状态量无关 —— 现在有两个关口一起证它。
assert.ok(!availableDecisions(st).some((x) => x.id === D), '没过永明的人,拿不到建武的题');
assert.deepEqual(availableDecisions(st).map((x) => x.id), [D0]);
});
test('★★ 不在那一户里 → 根本碰不到这个关口(状态量再高也不行)', () => {
// 士族清途:前序、状态量全给他喂满 —— 还是一道题都拿不到。
// ⚠ 2026-07-17 第四刀:前提由「位阶一样是 tier 4」改为「他在自己那条路上走得好,
// 但那是【另一把尺】」。理由与上一条测试同(南朝三轨),长注写在那边,不重复。
// ★ 旧前提(tier===4)是【加权求和 + 共用梯子】喂出来的:那条梯子的 tier4 逐字=中书通事舍人,
// 即「士族位阶与舍人一样高」为真的方式,正是【引擎把士族放进了舍人那一格】。
// ⇒ 改的是前提,【结论一个字没动】:位阶是杠杆,但杠杆得先握在你手里。
const noble = computeRank(profileFromSubs(['poetic'], { wang: 90 }), 'nanchao');
assert.equal(noble.channel.id, 'shizu', '前提:他走的是士族清途');
assert.equal(
sameScale('nanchao', noble.rank.unit, 'sheren'), false,
'前提:他的位阶与舍人的位阶【不在同一根轴上】—— 「一样高」这个问题问错了'
);
assert.ok(noble.rank.tier >= 3, '前提:他在自己的路上不低 —— 拿不到题不是因为他不行');
const st = {
rank: noble,
values: { quan: 99, ming: 99, li: 99, xian: 0 },
taken: [{ decisionId: D, optionId: 'shou-hu', ref: ref(D, 'shou-hu') }],
};
assert.deepEqual(availableDecisions(st), [], '★ 杠杆得先握在你手里 —— 位阶是杠杆,不是资格');
});
test('★ 一个不入流的人,开传当场就成传 —— 这不是失败,是这本书很短', () => {
// ★ 2026-07-17 论赞刀:魏晋摊进 DECISIONS 后,它的 tier0 题(orNoChannel)会命中不入流的样板人,
// ⇒ 魏晋不再是「空传」样例。改用【辽】:辽的三道题都在 tier1/4/5、都要通道,
// 而不入流的样板人在辽是 tier0、无通道 ⇒ 一道题都够不到,开传当场成传。
const noOne = computeRank(clerk(), 'liao');
assert.equal(noOne.channel, null, '前提:不入流(无通道)');
const L = openLineage({ subjectId: 'u', rank: noOne });
assert.deepEqual(idsOf(L), [], '一道题都没发到他手上(辽的题都在更高的格、都要通道)');
assert.equal(L.closed, true);
assert.equal(shizanForLineage(L), null, '空的书没有传末可写');
});
test('★ preconditionsOf:老板要的三件套(状态量 + 位阶 + 前序决策)齐了', () => {
const p = preconditionsOf(getDecision(D2));
assert.deepEqual(p.rank, { dynasty: 'nanchao', channelId: 'sheren', minTier: 4 }, '位阶');
assert.deepEqual(p.state, { quan: 0 }, '状态量');
assert.deepEqual(p.afterDecisions, [D], '前序决策');
assert.deepEqual(p.closedByOptions, [ref(D, 'zuo-shi')], '前序决策(永久关闭)');
// 状态量的 key 必须是 STATE_KEYS 里真有的
for (const k of Object.keys(p.state)) {
assert.ok(STATE_KEYS.some((s) => s.id === k), `未知状态量 ${k}`);
}
assert.equal(preconditionsOf(null), null);
});
// ══════════════════════════════════════════════════════════════════════════
// ★★★★ 2026-07-18 关口能动性刀(前瞻轴的三个原语)—— 先证明机器对、再填数据。
//
// 老板拍板要补的那根轴:选择【从不】改变你面对的下一个关口、也从不决定你活不活得到它。
// 三个原语(decisions.js,本刀【只加机器,不填数据】):
// A. preconditions.afterOptions —— 必须【这么选过】才开(closedByOptions 的正向补集)
// B. option.terminal —— 死/永久离场 → 传到此为止(replay.terminated / availableDecisions 返空)
// C. option.who —— 走谁的路(可选,缺则回落 persona;assertInvariants 认它,先不强制)
//
// ⚠ 现有关口一条 terminal / afterOptions / who 都没有 ⇒ 第一条断言先钉死【空转】:
// 现有数据全跑一遍,三原语一个都不触发,现有行为一字不动。后三条用【种进去又撤走的假题】
// 证机器真的会响(守卫自己不灵,等于没有守卫 —— 同本文件既有的「种一个错配置」诸测)。
// ══════════════════════════════════════════════════════════════════════════
// 种/撤假题的工具:availableDecisions 读【活的】DECISIONS,就地就能验,不碰任何现有关口数据。
const seedDecisions = (fakes, body) => {
DECISIONS.push(...fakes);
try { body(); }
finally {
for (const f of fakes) {
const i = DECISIONS.findIndex((d) => d.id === f.id);
if (i >= 0) DECISIONS.splice(i, 1);
}
}
};
// 一个合规的最小选项(事层、善终、不继承)。extra 里塞 terminal / who 做样张。
const okOpt = (id = 'x', extra = {}) => ({
id, text: '', persona: 'p', layer: 'shi', effects: {},
inherited: { value: false, why: 'w' }, fate: { score: 5 }, ...extra,
});
// 一道合规的最小假题(够得到那一户,史无载 + 签字)。
const fakeDecision = (id, extra = {}) => ({
id, dynasty: 'nanchao', title: `(测试)${id}`,
requiresRank: { dynasty: 'nanchao', channelId: 'sheren', minTier: 4 },
options: [okOpt()], historical: null, historicalNote: '(测试用,史无载)',
...extra,
});
test('★ 原语落地范围:terminal/who 落在李固(汉)与王衍(魏晋)两道;afterOptions 全仓仍空;南齐链 terminated 恒 false', () => {
// ══════════════════════════════════════════════════════════════════════════
// ★★ 2026-07-18 第二步(谏争真分岔落地)后,这条测试从「空转」改成【落地范围】:
// 前瞻轴不再空转 —— terminal/who 已填在【李固那一支】。
// ★★ 2026-07-19 关口能动性刀(多朝真分岔)后,落地范围【扩到魏晋王衍那一支】:
// terminal 现落在两支 —— 汉·李固(坚守本议→当场被诛)与魏晋·王衍(口不论世事→永嘉之乱为石勒填杀)。
// ⚠ 这是【allowlist 快照】:新增 terminal 必须是【真人真死+双证】(守根),别处仍一条不许有 ——
// 未来若有人塞进一条假死/无双证的 terminal,这条断言就该响。
// afterOptions 仍【没填】(后续关次优先),故全仓仍空。
// ══════════════════════════════════════════════════════════════════════════
const withTerminal = [];
const withWho = [];
for (const d of DECISIONS) {
const p = d.preconditions || {};
assert.deepEqual(p.afterOptions || [], [], `${d.id}: 这一刀未填 afterOptions(后续关次优先),此处必须仍空`);
for (const o of d.options || []) {
if (o.terminal !== undefined && !d.lite) withTerminal.push(ref(d.id, o.id));
if (o.who !== undefined && !d.lite) withWho.push(ref(d.id, o.id));
}
}
// terminal 落在李固(汉)与王衍(魏晋)两支(选它 = 传到此为止)。★ DECISIONS 顺序:汉在魏晋之前。
assert.deepEqual(withTerminal,
[ref('han-t5-jian-zheng', 'jian-zheng'), ref('weijin-t4-fa-yan', 'bu-yan')],
'★ terminal 只落在李固(汉)与王衍(魏晋)两支 —— 别的一条都不许有(新增必须真人真死+双证)');
// who 落在胡广、李固两支(汉,杜乔记在李固支的 cites 里)+ 王衍那支(魏晋 bu-yan)
// + 制度级察举加试·加试支(who=左雄,新制之主,非真死 ⇒ 不落 terminal,只落 who)。
assert.deepEqual(withWho.slice().sort(),
[ref('han-t5-jian-zheng', 'jian-zheng'), ref('han-t5-jian-zheng', 'wei-ling'), ref('weijin-t4-fa-yan', 'bu-yan'),
ref('han-zhidu-chaju-shi', 'jiashi')].sort(),
'★ who 落在胡广、李固(汉)、王衍(魏晋)与左雄(制度级察举加试·加试支)');
// ★ 南齐链无 terminal ⇒ terminated 恒 false(南齐是唯一摊进主线、走得完的一朝;其余朝未上线)。
for (const L of allLineages(nc())) {
assert.equal(replay(L.chain).terminated, false, `${traceOf(L)}: 南齐链无 terminal ⇒ terminated 恒 false`);
assert.equal(stateOf(L).terminated, false, `${traceOf(L)}: stateOf 透传的 terminated 也恒 false`);
}
});
test('★★★ 谏争真分岔(真数据):选李固 → 传到此为止;选胡广 → 活下去、还面对谳', () => {
// ══════════════════════════════════════════════════════════════════════════
// ★★ 老板拍板要补的那根轴,落在真数据上:同一格、同一间屋子,唯一的变量是选了谁 ——
// 选李固(带 terminal)你死在这里、传到此为止;选胡广你活下去、还面对下一关(谳)。
// ★ 「下一关」用的是【现成的】han-t5-yan(谳)—— tier5 一格两样(NOT_DONE ④),
// 胡广活着还够得到它,李固死了就够不到。⇒ terminal 一件,就把这道分岔做真了。
// 手搓一个三公(tier5,xiaolian 通道)—— 同本文件「在场翻过来」那条测试的手法。
// ══════════════════════════════════════════════════════════════════════════
const JIAN = 'han-t5-jian-zheng', YAN = 'han-t5-yan';
const rank = {
dynasty: { id: 'han', name: '汉' },
channel: { id: 'xiaolian', name: '察举孝廉' },
rank: { title: '九卿 / 三公', grade: '中二千石以上', unit: 'shi', tier: 5 },
};
const L0 = openLineage({ subjectId: 'sangong', rank });
const ids0 = idsOf(L0);
assert.ok(ids0.includes(JIAN) && ids0.includes(YAN), '前提:三公开局同时拿到谳与谏争');
assert.equal(L0.closed, false, '前提:开局没成传');
// ① 选李固(jian-zheng,带 terminal)⇒ terminated ⇒ 传到此为止:连谳也不发了,当场成传。
const Ldie = appendDecision(L0, JIAN, 'jian-zheng');
assert.equal(stateOf(Ldie).terminated, true, '★ 选李固 ⇒ terminated=true');
assert.deepEqual(idsOf(Ldie), [], '★★ 选李固 → 传到此为止,谳也不再发(死人不处当郡国谳)');
assert.equal(Ldie.closed, true, '★★ 当场成传');
// ② 选胡广(wei-ling,无 terminal)⇒ 活下去:谳还在,面对下一关。
const Llive = appendDecision(L0, JIAN, 'wei-ling');
assert.equal(stateOf(Llive).terminated, false, '★ 选胡广 ⇒ 没死');
assert.ok(idsOf(Llive).includes(YAN), '★★ 选胡广 → 还面对下一关(谳)');
assert.equal(Llive.closed, false, '★★ 传没完 —— 你活下去了');
// ③ ★ 守根①:两支位阶快照一字不差 —— 能动性只来自路径,绝不来自位阶。
assert.deepEqual(Ldie.rank, Llive.rank, '★★★ 两支 rank 快照一字不差 —— 分岔只来自选择,不来自 rank');
});
test('★★ 原语 A·afterOptions 真分岔:没【这么选过】不出现,选了才出现', () => {
// GATE 有两支;GATED 要求「必须选过 GATE 的 pick 那一支」才开。
const GATE = fakeDecision('zz-gate', {
title: '(测试)岔路口',
options: [okOpt('pick'), okOpt('skip')],
});
const GATED = fakeDecision('zz-gated', {
title: '(测试)走过那条岔路才碰得到',
preconditions: { afterOptions: [ref('zz-gate', 'pick')] },
});
seedDecisions([GATE, GATED], () => {
const rank = nc();
const idsWith = (taken) => availableDecisions({ rank, values: { ...INITIAL_VALUES }, taken }).map((d) => d.id);
// ① 没选过 ⇒ GATED 不出现(岔路口本身够得到)。
const before = idsWith([]);
assert.ok(before.includes('zz-gate'), '前提:岔路口本身够得到');
assert.ok(!before.includes('zz-gated'), '★ 没【这么选过】⇒ 那道后续题根本不出现');
// ② 答过 GATE 但选的是【别的一支】(skip) ⇒ 仍不出现 —— 这正是 afterDecisions 表达不了的那一半。
const tookSkip = [{ decisionId: 'zz-gate', optionId: 'skip', ref: ref('zz-gate', 'skip') }];
assert.ok(!idsWith(tookSkip).includes('zz-gated'), '★★ 选了别的一支 ⇒ 依然不出现(afterDecisions 只问「答过」)');
// ③ 选了 pick ⇒ 出现。
const tookPick = [{ decisionId: 'zz-gate', optionId: 'pick', ref: ref('zz-gate', 'pick') }];
assert.ok(idsWith(tookPick).includes('zz-gated'), '★★★ 选了那一支 ⇒ 后续题打开 —— 前瞻轴的真分岔');
});
});
test('★★ 原语 B·terminal 真截断:选了它 → replay.terminated=true、availableDecisions 返空', () => {
const cite = { what: '(测试)真人真死双证', source: 's', quote: 'q', caveat: '双证' };
const TERM = fakeDecision('zz-term', {
title: '(测试)传到此为止',
options: [
okOpt('die', { fate: { score: 0 }, terminal: { value: true, why: '你死在这里', cites: [cite] } }),
okOpt('live'),
],
});
seedDecisions([TERM], () => {
const rank = nc();
const av = (r) => availableDecisions({ rank, values: r.values, taken: r.taken, terminated: r.terminated });
// ① 选了 terminal(die)⇒ replay.terminated=true ⇒ 传到此为止,一道题都不发。
const rDie = replay([{ decisionId: 'zz-term', optionId: 'die', seq: 0 }]);
assert.equal(rDie.terminated, true, '★ 链上有 terminal 选项 ⇒ replay.terminated=true');
assert.deepEqual(av(rDie), [], '★★ 死了 → 余下关口一道都不发(修掉「人死了还在答题」)');
// ② 选了不死的那支(live)⇒ terminated=false ⇒ 传继续,还有下一关。
// ★ 两支 taken 结构同形,唯一变量是 terminated —— 坐实「返空」是 terminated 干的,不是别的。
const rLive = replay([{ decisionId: 'zz-term', optionId: 'live', seq: 0 }]);
assert.equal(rLive.terminated, false, '★ 没选 terminal ⇒ terminated=false');
assert.ok(av(rLive).length > 0, '★★ 活着 → 面对下一关');
});
});
test('★★★ 原语·assertInvariants:terminal 无 cites 拒收、afterOptions 悬空 ref 拒收、who 缺 name 拒收', () => {
// ── B:terminal.value===true 无 cites ⇒ 拒收(同「查不实宁可不写」)──
const badTerm = fakeDecision('zz-badterm', { options: [okOpt('x', { terminal: { value: true, why: 'w' } })] });
assert.equal(
assertInvariants([badTerm]).filter((s) => s.includes('terminal.value===true 却没有 terminal.cites')).length, 1,
'★ 一个「你死在这里」却不带双证 ⇒ 数据层拒收'
);
// 补上双证 ⇒ 收。
const okTerm = fakeDecision('zz-okterm', {
options: [okOpt('x', { fate: { score: 0 }, terminal: { value: true, why: 'w', cites: [{ source: 's', quote: 'q' }] } })],
});
assert.deepEqual(assertInvariants([okTerm]), [], '★ 带了双证的 terminal ⇒ 收');
// ── A:afterOptions 悬空 ref ⇒ 拒收(照 closedByOptions 同款)──
const badAfter = fakeDecision('zz-badafter', { preconditions: { afterOptions: [ref('nowhere', 'nope')] } });
assert.equal(
assertInvariants([badAfter]).filter((s) => s.includes('afterOptions 指向不存在的选项')).length, 1,
'★★ afterOptions 悬空 ref ⇒ 拒收'
);
// 指向本 list 内真实存在的选项 ⇒ 收(gate 一道、gated 一道,同真实用法)。
const a1 = fakeDecision('zz-a1');
const a2 = fakeDecision('zz-a2', { preconditions: { afterOptions: [ref('zz-a1', 'x')] } });
assert.deepEqual(assertInvariants([a1, a2]), [], '★ afterOptions 指向真实选项 ⇒ 收');
// ── C:who 给了却缺 name ⇒ 拒收(认它,但给了就得成形)──
const badWho = fakeDecision('zz-badwho', { options: [okOpt('x', { who: { ref: 'x' } })] });
assert.equal(
assertInvariants([badWho]).filter((s) => s.includes('who 给了却缺 name')).length, 1,
'★ who 给了却没名字 ⇒ 拒收(前端要拿它标「走谁的路」)'
);
// 给了 name ⇒ 收;且【完全不给 who】也收(先不强制)。
const okWho = fakeDecision('zz-okwho', { options: [okOpt('x', { who: { name: '李固' } })] });
assert.deepEqual(assertInvariants([okWho]), [], '★ who 有 name ⇒ 收');
assert.deepEqual(assertInvariants([fakeDecision('zz-nowho')]), [], '★ 完全不给 who ⇒ 照收(原语 C 先不强制)');
});
// ══════════════════════════════════════════════════════════════════════════
// 14. ★★ 确定性 —— 同一条决策链跑两次,结果完全一样
// ══════════════════════════════════════════════════════════════════════════
test('★★ 确定性:同一条决策链跑两次,结果完全一样', () => {
const build = () => {
let L = openLineage({ subjectId: 'u', rank: nc() });
L = appendDecision(L, D0, 'bu-ken-wang'); // ★ 第六刀:永明八年那一趟江
L = appendDecision(L, D, 'shou-hu');
L = appendDecision(L, D2, 'qi-zuo');
return L;
};
const a = build();
const b = build();
assert.deepEqual(a, b, '同一条链跑两次不一致 —— 用户跑两次就发现是瞎编的');
assert.equal(a.id, b.id, '★ lineage.id 必须是拼出来的,不许 uuid/随机数');
assert.deepEqual(replay(a.chain), replay(b.chain));
assert.deepEqual(stateOf(a), stateOf(b));
assert.deepEqual(availableDecisions(stateOf(a)), availableDecisions(stateOf(b)));
assert.deepEqual(
shizanForLineage(a, { person: { name: '某' } }),
shizanForLineage(b, { person: { name: '某' } }),
'传末断语也必须确定性 —— 它是断语,不是 LLM 生成'
);
});
test('★★ 确定性:链上【没有时间戳】—— 有它就有了「几点」,那正是★四红线的入口', () => {
const L = appendDecision(open1(), D, 'shou-hu');
for (const step of L.chain) {
assert.deepEqual(
Object.keys(step).sort(), ['decisionId', 'optionId', 'seq'],
'链上只许有这三个字段 —— 时间是持久化层的事,不是规则引擎的事'
);
}
assert.ok(!/\d{4}-\d{2}-\d{2}T/.test(JSON.stringify(L)), '传里不许出现时间戳');
});
test('replay:坏链不许静默通过', () => {
assert.throws(() => replay([{ decisionId: 'nope', optionId: 'x' }]), /未知的关口/);
assert.throws(() => replay([{ decisionId: D, optionId: 'nope' }]), /未知的选项/);
assert.deepEqual(replay([]).values, { ...INITIAL_VALUES }, '空链 = 起点');
});
// ══════════════════════════════════════════════════════════════════════════
// 15. ★★★ 结构律★三:属性只长不调 —— 不是滑块,是起居注
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 属性只长不调:没有任何「重塑性格/洗点/调属性」的接口', () => {
for (const n of Object.keys(Dec)) {
assert.ok(
!/^set[A-Z]|reshape|respec|retrain|tunestat|adjuststat|setvalues|setaxes/i.test(n),
`导出「${n}」看着像能调属性 —— ` +
'「你什么都没变,天花板全变了」这句话的全部重量压在「你什么都没变」上'
);
}
});
test('★★★ 状态量【只能由链推出】,没有第二个来源', () => {
assert.ok(Object.isFrozen(INITIAL_VALUES), '起点冻结');
assert.throws(() => { INITIAL_VALUES.li = 99; }, TypeError);
const L = appendDecision(open1(), D, 'shou-hu');
// ★ 拿到 stateOf 的返回值随便改 —— 改不了那部书
const s = stateOf(L);
s.values.li = 999;
s.values.quan = -999;
assert.equal(stateOf(L).values.li, 3, '状态量每次都从链重算 —— 改返回值改不了那部书');
assert.deepEqual(idsOf(L), [D2], '也就改不了「哪个关口对你开着」');
});
test('★★★ 位阶是【开传那一刻的快照】—— 不许刷起居注去够关口', () => {
const L = open1();
assert.ok(Object.isFrozen(L.rank), '快照冻结:谁也改不动这部书里的那个人');
assert.throws(() => { L.rank.rank.tier = 5; }, TypeError);
assert.equal(stateOf(L).rank, L.rank, '判定用的是传里那张快照,不是现算的');
// 有 stat gate 的地方必然有 stat 优化 —— 快照就是拦这个的
const snap = rankSnapshot(nc());
assert.equal(snap.channel.id, 'sheren');
assert.equal(snap.rank.tier, 4);
assert.equal(snap.reasons, undefined, '快照只留判定用得上的,不搬整个 computeRank 结果');
});
// ══════════════════════════════════════════════════════════════════════════
// 16. ★★★ 结构律★四:连载不是体力【红线】
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 红线:数据和结构里不许有体力/次数/冷却/每日上限', () => {
assert.deepEqual(assertNoGameMechanics(), [], '决策点数据里有手游机制');
const L = appendDecision(open1(), D, 'shou-hu');
assert.deepEqual(assertNoGameMechanics(L), [], '传的结构里也不许有闸门字段');
assert.deepEqual(assertNoGameMechanics(stateOf(L)), []);
assert.deepEqual(assertNoGameMechanics(shizanForLineage(appendDecision(L, D2, 'bu-qi'))), []);
});
test('★★★ 红线的守卫【真的会响】—— 种一个进去试试', () => {
// 守卫自己不灵,等于没有守卫。这条测试守的是守卫。
assert.ok(assertNoGameMechanics({ a: { dailyLimit: 3 } }).some((x) => /dailyLimit/.test(x)));
assert.ok(assertNoGameMechanics({ a: { stamina: 5 } }).some((x) => /stamina/.test(x)));
assert.ok(assertNoGameMechanics({ a: { cooldownUntil: 1 } }).length > 0);
assert.ok(assertNoGameMechanics({ copy: '今天没次数了,明天再来' }).some((x) => /今天没次数/.test(x)));
assert.ok(assertNoGameMechanics({ copy: '体力不足' }).some((x) => /体力/.test(x)));
});
test('★★★ 连载是【内容】语义,不是闸门 —— SERIAL_NOTE 挡不住任何人', () => {
assert.equal(SERIAL_NOTE.framing, '连载');
assert.match(SERIAL_NOTE.copy, /这一节写完了|下一节/, '说的是【书写到哪了】');
assert.ok(!/次数|体力|冷却/.test(SERIAL_NOTE.copy), '不许说成【你被挡住了】');
assert.notEqual(typeof SERIAL_NOTE, 'function', '★ 它是一句文案 —— 不吃参数、不返回 false、挡不住任何调用');
assert.ok(Object.isFrozen(SERIAL_NOTE));
for (const n of Object.keys(Dec)) {
assert.ok(
!/^canWrite|remaining|nextAvailable|refill|hasQuota/i.test(n),
`导出「${n}」是闸门 —— ★四红线:连载不是体力`
);
}
});
// ══════════════════════════════════════════════════════════════════════════
// 17. ★★ 结构律★五:一朝成传,再下一朝
// ══════════════════════════════════════════════════════════════════════════
test('★★ 一朝成传:链走完 → 自动成传,不用谁记得去关', () => {
const L = appendDecision(appendDecision(open1(), D, 'shou-hu'), D2, 'bu-qi');
assert.equal(L.closed, true);
assert.deepEqual(idsOf(L), []);
});
test('★★ 不同时开二十四个坑:还有没写完的,不许开新的', () => {
const L = open1();
assert.equal(L.closed, false, '前提:这一部还没写完');
assert.throws(
() => openLineage({ subjectId: 'u', rank: nc(), existing: [L] }),
/没写完|二十四个坑/,
);
// 写完了才放行
const done = appendDecision(L, D, 'fei-hu');
assert.equal(done.closed, true);
assert.ok(openLineage({ subjectId: 'u', rank: nc(), existing: [done], edition: 2 }));
});
test('★★ 一朝一传:这部传不收别朝的关口', () => {
// 手搓一部「唐」的传(openLineage 造不出来——唐没有决策点,开出来当场就成传)
const fake = {
id: 'u/tang/1', subjectId: 'u', dynasty: 'tang', edition: 1,
precededBy: null, rank: nc(), chain: [], closed: false,
};
assert.throws(() => appendDecision(fake, D, 'shou-hu'), /一朝成传,再下一朝/);
});
test('★ 开传的守卫:传主和朝代缺一不可', () => {
assert.throws(() => openLineage({ rank: nc() }), /传主/);
assert.throws(() => openLineage({ subjectId: 'u' }), /朝代/);
assert.throws(() => appendDecision(null, D, 'shou-hu'), /没有传/);
assert.throws(() => appendDecision(open1(), 'nope', 'x'), /没有这个关口/);
assert.throws(() => appendDecision(open1(), D, 'nope'), /没有这个选项/);
assert.throws(() => anotherEdition(null), /没有上一版/);
});
// ══════════════════════════════════════════════════════════════════════════
// 18. ★★ 穷举:这一朝每一部走得完的传
// ══════════════════════════════════════════════════════════════════════════
test('★★ 穷举南齐所有走得完的传:每一部都合体例、都不重韵', () => {
const all = allLineages(nc());
// ★★ 2026-07-17 第六刀:6 → 18。
// 关口三(宣旨慰劳,永明八年)三个选项 × 原先那 6 部 = 18。
// ⇒ 原先那 6 部的算法一个字没变(关口一四个选项:守户 → 关口二三个选项 = 3 部;
// 求名/著令/废户各自当场成传 = 3 部),只是每一部前面都多了一趟江。
// ⚠ 3 × 6 = 18 【必须真的是乘法】—— 那正是「宣旨那三个选项一个都不改关口一/二的开闭」
// 的可执行形式:任何一个选项若动了 quan,18 就不再是 18。★ 故这个数【是一条断言,不是记录】。
assert.equal(all.length, 18, `南齐样张应当正好有 18 部走得完的传(3 × 6),实得 ${all.length}`);
for (const L of all) {
const trace = traceOf(L);
assert.equal(L.closed, true, `${trace}: 每一条链都走得完 —— 没有失败态`);
const s = shizanForLineage(L, { person: { name: '某' } });
assert.ok(s, `${trace}: 该有传末断语`);
assert.equal(s.zan.length, 8, `${trace}: 赞八句成篇`);
assert.deepEqual(s.violations, [], `${trace}: 赞不合体例 —— ${s.violations.join(';')}`);
assert.equal(
new Set(s.rhyme.feet).size, s.rhyme.feet.length,
`${trace}: 重韵(${s.rhyme.feet.join('、')})—— 起承转合四库的韵脚撞了`
);
assert.match(s.shichen, /^史臣曰:/, `${trace}: 发语词必须是「史臣曰」`);
assert.equal(s.steps.length, L.chain.length, `${trace}: 传末要把走过的每一格都列出来`);
assert.ok([0, 50, 200].includes(s.verdict.years), `${trace}: 只许三档`);
}
});
test('★★ 每个关口的每个选项,关口级断语都拼得出来(加关口必须同时加起/承/转三库)', () => {
for (const d of DECISIONS) {
for (const o of d.options) {
const s = shizanYue({ decisionId: d.id, optionId: o.id, person: { name: '某' } });
assert.ok(s, `${d.id}/${o.id} 没有断语`);
assert.deepEqual(s.violations, [], `${d.id}/${o.id} 的断语不合体例:${s.violations.join(';')}`);
// ★ 四言韵朝(汉/南齐)查重韵;散句判词朝(唐宋明魏晋辽)rhyme=null、无四言赞,无韵脚可检。
if (s.rhyme) {
assert.equal(new Set(s.rhyme.feet).size, s.rhyme.feet.length, `${d.id}/${o.id} 重韵`);
} else {
assert.deepEqual(s.zan, [], `${d.id}/${o.id} 散句判词朝不该有四言赞`);
}
}
}
});
test('★★ 传末:承取【最深的那一念】,合取【传得最远的那一样】—— 故意不是同一步', () => {
let L = appendDecision(open1(), D, 'shou-hu'); // 事:弥合,0 年,善终
L = appendDecision(L, D2, 'qing-shu-shi'); // 道:50 年,不得其死
const s = shizanForLineage(L);
assert.equal(s.verdict.deepestLayer, 'dao', '一生最深的那一念');
assert.equal(s.verdict.years, 50, '传得最远的那一样');
assert.equal(s.verdict.worstFate, 0, '你只死一次 —— 取最坏的那个下场');
assert.match(s.verdict.note, /一票都投不了/, '★ 制的存亡不在你手上,这句话必须出现在传末');
});
test('传没写完 → 没有传末断语(这不是错误,是书还没写到那儿)', () => {
assert.equal(shizanForLineage(appendDecision(open1(), D, 'shou-hu')), null);
assert.equal(shizanForLineage(null), null);
});
// ══════════════════════════════════════════════════════════════════════════
// 19. 关口二的史学回归 —— 每一条都是这一轮回原书 curl 核出来的
// ══════════════════════════════════════════════════════════════════════════
test('★ 回归:纪僧真那段是【逸事孤证】,记在江斅本传里,不可读成制度', () => {
const c = getDecision(D2).situationCites.find((x) => /纪僧真|前车/.test(x.what));
assert.ok(c, '关口二必须挂纪僧真那条');
assert.match(c.source, /南史/);
assert.match(c.source, /江斅/, '在江斅本传,不在《恩倖传》—— 这故事是【夸江斅】的');
assert.match(c.caveat, /孤证/);
assert.match(c.caveat, /不可读成/);
assert.match(c.caveat, /双证/, '双证过的必须说明是哪两个见证');
});
test('★ 回归:《南史》作「移吾床【让】客」——「远客」是顾炎武的字,不许复活', () => {
const d = getDecision(D2);
const quotes = [
d.situation,
...d.situationCites.map((x) => x.quote),
...d.historicalCites.map((x) => x.quote),
...d.options.flatMap((o) => (o.fate?.cites || []).map((x) => x.quote)),
];
for (const q of quotes) {
assert.ok(!q.includes('移吾床远客'), `「远客」出《日知录·流品》,《南史》作「让客」:${q.slice(0, 20)}`);
assert.ok(!q.includes('移我远客'), '「移我远客」实出另一则移床故事(《宋书》卷六十二·张敷传)');
}
const c = d.situationCites.find((x) => /纪僧真|前车/.test(x.what));
assert.match(c.quote, /移吾床让客/);
assert.match(c.caveat, /日知录/, '★ caveat 必须点破顾炎武把末句从纪僧真自言改成了齐武帝语');
assert.match(c.caveat, /自言/, '末句是【败退者的自嘲】,不是天子的判词');
});
test('★★ 回归:「五十年中,有此三事」是沈约【自己在数】—— 逸事非制度的史源内证', () => {
const c = getDecision(D2).situationCites.find((x) => /五十年/.test(x.what));
assert.ok(c);
assert.match(c.quote, /五十年中,有此三事/);
assert.match(c.source, /宋书/);
assert.match(c.source, /蔡廓传|蔡兴宗/);
assert.match(c.caveat, /当代史/, '★ 沈约记刘宋是当代史,比唐人追修的《南史》硬');
assert.match(c.caveat, /刘宋|非南齐|不可充齐制/, '⚠ 刘宋事,不是南齐');
assert.match(c.caveat, /王弘|徐爰/, '人名争议(钱大昕/李慈铭)必须交代');
// 三事俱在本段之内 —— 这是「有此三事」读得实的前提
assert.match(c.quote, /王道隆/);
assert.match(c.quote, /秋当/);
assert.match(c.quote, /若不得尔/);
});
test('★★ 回归:王球「国之章也」是【拒诏时的说辞】,不是典章条文', () => {
const c = getDecision(D2).situationCites.find((x) => /王球|国之章/.test(x.what));
assert.ok(c);
assert.match(c.quote, /士庶区别,国之章也/);
assert.match(c.quote, /上改容谢焉/, '★ 皇帝下了诏,被顶回来,还道了歉');
assert.match(c.caveat, /不可读成制度/);
assert.match(c.caveat, /刘毅/, '★ 必须点明它与「上品无寒门」同样的性质:政论,不是制度描述');
assert.match(c.caveat, /政论/);
assert.match(c.caveat, /刘宋|非南齐|不可充齐制/);
});
test('★ 关口二:推演的下场不许挂史料,且必须自己说明是推演', () => {
const d = getDecision(D2);
for (const id of ['bu-qi', 'qing-shu-shi']) {
const o = d.options.find((x) => x.id === id);
assert.match(o.fate.text, /推演,非史事/, `${id}: 推演的下场必须明说它是推演`);
assert.deepEqual(o.fate.cites, [], `${id}: 推演不许挂史料 —— 带卷次的错误比没出处的错误危害大十倍`);
}
// 而纪僧真那一格是【真事】,它该挂史料
const qi = d.options.find((x) => x.id === 'qi-zuo');
assert.ok(qi.fate.cites.length > 0, '纪僧真真的开过这个口 —— 这一格挂得住史料');
assert.ok(!/推演,非史事/.test(qi.fate.text), '真事不许标成推演');
});
test('★ 关口二的历史答案是纪僧真,而茹法亮那一格是【沉默】,不许替它填空', () => {
const d = getDecision(D2);
assert.equal(d.historical, 'qi-zuo');
assert.match(d.historicalNote, /纪僧真/);
assert.match(d.historicalNote, /无载|沉默/, '★ 茹法亮是否乞过,史无载 —— 那是沉默,不是记载');
assert.match(d.historicalNote, /永明/, '⚠ 时序:僧真事在永明,早于本关口十年,是【前车】');
assert.ok(d.historicalCites.length > 0);
});
test('★★ 关口二的要害:求名而名【减】—— 你去要那张席,当众少了一点东西', () => {
const qi = getDecision(D2).options.find((x) => x.id === 'qi-zuo');
assert.ok(qi.effects.ming < 0, '★ 这一格是本关口的全部立意:他去求名,名反而更少了');
assert.equal(computeImpact(D2, 'qi-zuo').years, 0, '改「事」的必被弥合');
});
test('★ 引旁证必须标明朝代 —— situationCites 也一样(梁、陈、刘宋都不是南齐)', () => {
for (const d of DECISIONS) {
for (const c of [...(d.situationCites || []), ...(d.historicalCites || [])]) {
if (/《隋书》/.test(c.source)) {
assert.match(c.caveat, /属梁|属陈|非齐制|不可充齐制/, `${c.what}: 隋书记的梁陈制不是齐制`);
}
if (/《宋书》/.test(c.source)) {
assert.match(c.caveat, /刘宋|非南齐|不可充齐制/, `${c.what}: 宋书是刘宋事,不是南齐`);
}
}
}
});
test('★ 加决策点必须同时加 preconditions 的引用,且必须解析得到', () => {
// assertInvariants 已经逐条解析过一遍了,这里守的是【它真的在解析】
assert.deepEqual(assertInvariants(), []);
const ids = new Set(DECISIONS.map((d) => d.id));
for (const d of DECISIONS) {
const p = preconditionsOf(d);
for (const id of p.afterDecisions) assert.ok(ids.has(id), `${d.id}: afterDecisions 指向不存在的关口 ${id}`);
for (const r of p.closedByOptions) {
const [did, oid] = r.split('/');
assert.ok(ids.has(did), `${d.id}: closedByOptions 指向不存在的关口 ${did}`);
assert.ok(getDecision(did).options.some((o) => o.id === oid), `${d.id}: closedByOptions 指向不存在的选项 ${r}`);
}
}
});
// ══════════════════════════════════════════════════════════════════════════
// 20. ★★★ 2026-07-17 第七刀(体例·第二种)—— 汉《后汉书》体例
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 汉·《后汉书》体例:六格的汉传,逐条 buildZhuan + 传末断语都合体例(论曰 + 职部赞)', async () => {
// ★ 从 rank 出发,不从链出发:汉三条通道 kemu 今天全空 ⇒ 真 computeRank 只给 tier0/tier3,
// tier1/2/4/5 一部传都走不出来。而那六道题【写好了、要被验】(没上线不等于不验)。
// ⇒ 手搓每一格的 rank(同 lineage.test.js 的空链扫,只是这里走【完整的链】)。
// ★★ 它钉的正是「加回 HAN_DECISIONS 不是八个 500」:每一部汉传都必须 buildZhuan 成、
// 传末断语拼得出、发语词是【论曰】、赞押【职部】、不重韵。
const { buildZhuan } = await import('../src/zhuan.js');
const HAN_LADDER = [
{ tier: 0, title: '郡县小吏', grade: '斗食' },
{ tier: 1, title: '郡决曹史', grade: '百石' },
{ tier: 2, title: '郎', grade: '比三百石' },
{ tier: 3, title: '县令', grade: '六百石' },
{ tier: 4, title: '郡太守', grade: '二千石' },
{ tier: 5, title: '九卿 / 三公', grade: '中二千石以上' },
];
const walk = (L, acc) => {
if (L.closed) { acc.push(L); return acc; }
for (const dd of availableDecisions(stateOf(L))) {
for (const o of dd.options) acc = walk(appendDecision(L, dd.id, o.id), acc);
}
return acc;
};
let total = 0;
for (const step of HAN_LADDER) {
// tier0 无通道(单轨最低处,orNoChannel);其余在 xiaolian 那条路上(三条共一条 ladder,取一条即可)。
const rank = {
dynasty: { id: 'han', name: '汉' },
channel: step.tier === 0 ? null : { id: 'xiaolian', name: '察举孝廉' },
rank: { title: step.title, grade: step.grade, unit: 'shi', tier: step.tier },
};
const all = walk(openLineage({ subjectId: 'han-sweep', rank }), []);
assert.ok(all.length > 0, `tier${step.tier}: 至少走得出一部传`);
for (const L of all) {
const path = L.chain.map((s) => s.optionId).join('→');
const z = buildZhuan(L, { person: { name: '某' } });
assert.ok(z, `tier${step.tier} [${path}]: 成传返回 null —— 这正是「八个 500」`);
assert.deepEqual(z.violations, [], `tier${step.tier} [${path}] 正文体例违规:${JSON.stringify(z.violations)}`);
assert.match(z.tili, /后汉书/, `tier${step.tier}: 拟的是《后汉书》体例,不是《汉书》`);
const s = z.shizan;
assert.ok(s, `tier${step.tier} [${path}]: 该有传末断语(若缺骨干句/句库就是「八个 500」)`);
assert.deepEqual(s.violations, [], `tier${step.tier} [${path}] 赞/发语词违规:${JSON.stringify(s.violations)}`);
assert.match(s.shichen, /^论曰:/, '★ 汉的发语词是【论曰】,不是史臣曰');
assert.ok(!/史臣曰/.test(s.shichen), '★ 汉的断语里绝不许出现【史臣曰】(那是萧子显的发语词)');
// ★ 2026-07-20 回响B:制度级关口「察举加试」(sanZan) 落在汉 tier3 ⇒ 但凡它在链上,整部传走散句判词、
// 无四言赞、rhyme 为 null(老板「差不多不掰死理」,判词全在论曰散文里)。tier0 无察举加试 ⇒ 仍纯四言职部赞,覆盖不丢。
if (s.rhyme) {
assert.equal(s.rhyme.id, 'zhi', '★ 汉押【职部】,不是南齐的真部');
assert.equal(new Set(s.rhyme.feet).size, s.rhyme.feet.length, `tier${step.tier} [${path}] 重韵:${s.rhyme.feet.join('、')}`);
} else {
assert.equal(s.zanText, '', `tier${step.tier} [${path}]: sanZan 散句传无四言赞`);
assert.ok(L.chain.some((st) => st.decisionId === 'han-zhidu-chaju-shi' || DECISIONS.find((d) => d.id === st.decisionId)?.lite),
`tier${step.tier} [${path}]: 散句传必因 sanZan(察举加试) 或 lite 解耦题在链上`);
}
total += 1;
}
}
assert.ok(total >= 8, `只走了 ${total} 部汉传 —— 太少,穷举没跑对`);
});
test('★★ assertTiyuan:一篇断语里绝不许出现【别一部书】的发语词(论曰/史臣曰 各归各书)', () => {
// 南齐用「史臣曰」,不含「论曰」(这正是那个正在上线的 bug:论曰曾被塞进每一部南齐传)。
const nq = shizanYue({ decisionId: D, optionId: 'shou-hu', person: { name: '某' } });
assert.match(nq.shichen, /^史臣曰:/);
assert.ok(!/论曰/.test(nq.shichen), '★ 南齐断语里不许出现「论曰」—— 已修');
assert.deepEqual(assertTiyuan(nq.shichen, 'nanchao'), []);
assert.deepEqual(nq.violations, [], '南齐断语自检必须全绿');
// 汉用「论曰」,不含「史臣曰」。
const han = shizanYue({ decisionId: 'han-t5-jian-zheng', optionId: 'jian-zheng', person: { name: '某' } });
assert.match(han.shichen, /^论曰:/);
assert.ok(!/史臣曰/.test(han.shichen), '★ 汉断语里不许出现「史臣曰」');
assert.deepEqual(assertTiyuan(han.shichen, 'han'), []);
assert.deepEqual(han.violations, [], '汉断语自检必须全绿(赞 + 发语词)');
// ★ 守卫必须真的会响 —— 种几个进去。
assert.ok(assertTiyuan('史臣曰:某。论曰:某。', 'nanchao').some((x) => /论曰/.test(x)), '南齐混进论曰 → 必报');
assert.ok(assertTiyuan('论曰:某。史臣曰:某。', 'han').some((x) => /史臣曰/.test(x)), '汉混进史臣曰 → 必报');
assert.ok(assertTiyuan('无发语词', 'han').some((x) => /没有发语词/.test(x)), '缺发语词 → 必报');
assert.ok(assertTiyuan('论曰:甲。论曰:乙。', 'han').some((x) => /出现了 2 次/.test(x)), '一篇两个论曰 → 必报');
assert.ok(assertTiyuan('论曰:某', 'zhou').some((x) => /未知体例/.test(x)), '没有体例的朝代 → 必报');
});
test('★★ assertYun:守卫韵表本身 —— 混进非汉字、韵部相交都必须报', () => {
assert.deepEqual(assertYun(), [], '现行两个韵部(真部/职部)都干净');
// 曾经真有一个谚文「식」误入职部 —— 守卫必须挡下来。
const bad = { zhi: { id: 'zhi', name: '入声十三职', chars: new Set(['职', '식']) } };
assert.ok(assertYun(bad).some((x) => /非汉字|식/.test(x)), '★ 非汉字韵脚必须被守卫挡下来');
const overlap = {
a: { id: 'a', name: 'A', chars: new Set(['人']) },
b: { id: 'b', name: 'B', chars: new Set(['人']) },
};
assert.ok(assertYun(overlap).some((x) => /两个韵部|交集/.test(x)), '★ 两个韵部相交必须被守卫挡下来');
});
test('★ shuMingOf:书名由体例数据查 —— 汉是《后汉书》不是《汉书》', () => {
assert.equal(shuMingOf('han'), '后汉书', '★ 引擎的汉=东汉 ⇒ 拟《后汉书》,不是《汉书》');
assert.equal(shuMingOf('nanchao'), '南齐书');
// ★ 2026-07-17 论赞刀:五朝体例做完后,书名也进了 TIYUAN.shu。
assert.equal(shuMingOf('tang', '唐书'), '新唐书', '★ 唐拟《新唐书》(非《旧唐书》,本表史料出新唐书)');
assert.equal(shuMingOf('song'), '宋史');
assert.equal(shuMingOf('weijin'), '晋书', '★ 魏晋拟《晋书》');
// 还没有体例的朝代(秦,不在这七朝里)回落到调用方给的字,不抛。
assert.equal(shuMingOf('qin', '秦书'), '秦书', '没有体例的朝代回落到调用方给的字,不抛');
});