2120 lines
139 KiB
JavaScript
2120 lines
139 KiB
JavaScript
// 决策点 + 史臣曰的单测 —— 与 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', '秦书'), '秦书', '没有体例的朝代回落到调用方给的字,不抛');
|
||
});
|