dsh-metaphysics 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/index.js CHANGED
@@ -5,13 +5,14 @@ import { buildZiwei, BRIGHTNESS, flowOf, asOfParts, ZIWEI_SCHOOL_DEFAULTS } from
5
5
  import { drawTarot } from './tarot.js';
6
6
  import { buildMeihua } from './meihua.js';
7
7
  import { buildDaliuren } from './daliuren.js';
8
- import { buildHepan } from './hepan.js';
8
+ import { buildHepan, tripleOf } from './hepan.js';
9
9
  import { buildZiweiHepan } from './ziwei-hepan.js';
10
10
  import { buildQimen, buildSnapshot } from './qimen.js';
11
11
  import { geocodeAddress, resolveAddress } from './geo.js';
12
12
  import { toJDN, ganzhiName, dayGanzhiIndex } from './calendar.js';
13
13
  import { randomOf, isSeed } from './random.js';
14
14
  import { encodeHandle, decodeHandle, resolveHandle } from './handle.js';
15
+ import { askLink, parseAskLink, formOf, restNote, askPackages } from './bridge.js';
15
16
  import { parseRevision, displayValue } from './revise.js';
16
17
  import { interpret, DIRECTIONS, DIRECTION_DEFAULT } from './interpret.js';
17
18
  import { followup, askPrompt } from './followup.js';
@@ -27,7 +28,7 @@ import { buildNumerology } from './numerology.js';
27
28
  import { buildMbti, mbtiPaper } from './mbti.js';
28
29
  import { buildConvert } from './convert.js';
29
30
  import { classicsFor, classicsText, suiyunClassicOf, classicOf, CLASSIC_BOOKS } from './classics.js';
30
- import { diffObjects, summarize, sortDiffs, labelOf, rootOf, categoryOf, formatValue, CALIBER_SWITCHES } from './diff.js';
31
+ import { diffObjects, summarize, sortDiffs, labelOf, rootOf, categoryOf, formatValue, CALIBER_SWITCHES, viewRows } from './diff.js';
31
32
  import { canonicalJson, fingerprintOf } from './fingerprint.js';
32
33
  import { buildTrend } from './trend.js';
33
34
  import { buildFengshui } from './fengshui.js';
@@ -297,6 +298,26 @@ function qimenText(r) {
297
298
  ].join('\n');
298
299
  }
299
300
 
301
+ function tripleText(t) {
302
+ if (!t || !Array.isArray(t.pairs) || !t.pairs.length) return [];
303
+ const lines = [
304
+ '【三方合盘】三对两两并列,不合成「三人总分」',
305
+ ...t.pairs.map((p) => `· ${p.a}–${p.b}:${p.level || '—'}(${p.total == null ? '—' : `${p.total}/100`})`),
306
+ ];
307
+ if (t.best) {
308
+ // 差 1–2 分时点名「最顺/最需磨合」是假精确:三对分数接近就直说接近,不排名
309
+ if (t.spread >= 3) {
310
+ lines.push(` 最顺:${t.best.a}–${t.best.b}(${t.best.total}/100)`
311
+ + (t.hardest ? `;最需磨合:${t.hardest.a}–${t.hardest.b}(${t.hardest.total}/100)` : ''));
312
+ } else {
313
+ lines.push(` 三对分数接近(最高与最低相差 ${t.spread} 分),不排名`);
314
+ }
315
+ }
316
+ if (t.focus && t.focus.spread >= 3) lines.push(` 差异最大的一维:${t.focus.label}(三对相差 ${t.focus.spread} 分)`);
317
+ if (t.note) lines.push(`※ ${t.note}`);
318
+ return lines;
319
+ }
320
+
300
321
  function hepanText(r) {
301
322
  if (r.chart === 'ziwei') {
302
323
  const d = (x) => `${x.label}:${x.score >= 0 ? '+' : ''}${x.score} ${x.text}`;
@@ -306,6 +327,7 @@ function hepanText(r) {
306
327
  `对方 命宫 ${r.b.soul.branch}(${r.b.soulStars.join('、') || '无主星'}) 夫妻 ${r.b.spouseStars.join('、') || '无主星'} ${r.b.bureau}`,
307
328
  ...r.dimensions.map(d),
308
329
  ...r.interactions.map((h) => ` · ${h.text}`),
330
+ ...tripleText(r.triple),
309
331
  ...r.notes.map((n) => `※ ${n}`),
310
332
  ].join('\n');
311
333
  }
@@ -329,6 +351,7 @@ function hepanText(r) {
329
351
  `年命纳音:${r.detail.nayin.a} / ${r.detail.nayin.b}(${r.detail.nayin.label})——${r.detail.nayin.text}`,
330
352
  `吉凶象:吉 ${r.detail.tally.good} / 凶 ${r.detail.tally.bad}(${r.detail.tally.lean};分档只数象,总分以上面三维度为准)`,
331
353
  ] : []),
354
+ ...tripleText(r.triple),
332
355
  ...r.notes.map((n) => `※ ${n}`),
333
356
  ].join('\n');
334
357
  }
@@ -559,11 +582,14 @@ function shadowParams(args, result) {
559
582
  };
560
583
  }
561
584
  if (system === 'hepan') {
562
- return {
585
+ const params = {
563
586
  chart: args.chart === 'ziwei' ? 'ziwei' : 'bazi',
564
587
  a: shadowPerson({ ...args, datetime: args.datetime }),
565
588
  b: shadowPerson(args.partner || {}),
566
589
  };
590
+ // 第三方进指纹:同一对加上不同的第三人,是另一组盘
591
+ if (args.partner2) params.c = shadowPerson(args.partner2);
592
+ return params;
567
593
  }
568
594
 
569
595
  // 其余体系:只剔除解析/附加模块参数,其余原样归一化
@@ -720,6 +746,11 @@ export function compute(input = {}) {
720
746
  ignored: rev.ignored,
721
747
  };
722
748
  }
749
+ // partner2: null 视同未传:先归一化再排盘,否则同一对盘会因为显式 null 多出一个句柄
750
+ if (args.partner2 === null) {
751
+ args = { ...args };
752
+ delete args.partner2;
753
+ }
723
754
  const r = jsonSafe(computeRaw(args));
724
755
  if (revision) r.revision = revision;
725
756
  if (r && r.system === 'bazi') {
@@ -901,11 +932,12 @@ export function caliberCompare(args = {}, opts = {}) {
901
932
  const alt = sw.alt(current);
902
933
  let diffs = [];
903
934
  let error = '';
935
+ let cur = null;
936
+ let altRes = null;
904
937
  try {
905
- diffs = diffObjects(
906
- compute({ ...base, [sw.key]: current }),
907
- compute({ ...base, [sw.key]: alt }),
908
- );
938
+ cur = compute({ ...base, [sw.key]: current });
939
+ altRes = compute({ ...base, [sw.key]: alt });
940
+ diffs = diffObjects(cur, altRes);
909
941
  } catch (e) {
910
942
  error = (e && e.message) || String(e);
911
943
  }
@@ -933,6 +965,7 @@ export function caliberCompare(args = {}, opts = {}) {
933
965
  altLabel: sw.label(alt),
934
966
  total: diffs.length,
935
967
  summary: error ? '' : summarize(diffs),
968
+ views: error ? [] : viewRows(system, cur, altRes),
936
969
  roots,
937
970
  categories,
938
971
  error,
@@ -1045,31 +1078,42 @@ function computeRaw(args = {}) {
1045
1078
  if (!partner.datetime) {
1046
1079
  throw fail('E_PARTNER', '缺少 partner.datetime(对方的公历出生时间),例如 partner: {"datetime":"1992-03-03T10:00","gender":"female"}');
1047
1080
  }
1048
- const pa = parseDateTime(args.datetime);
1049
- const pb = parseDateTime(partner.datetime);
1081
+ const partner2 = args.partner2;
1082
+ if (partner2 !== undefined && partner2 !== null) {
1083
+ if (typeof partner2 !== 'object' || Array.isArray(partner2)) {
1084
+ throw fail('E_PARTNER', 'partner2 需要 { datetime, gender, address? };不想看三方就整段省略(null 视同未传)');
1085
+ }
1086
+ if (!partner2.datetime) {
1087
+ throw fail('E_PARTNER', '缺少 partner2.datetime(第三方的公历出生时间),例如 partner2: {"datetime":"1988-09-09T08:00","gender":"male"}');
1088
+ }
1089
+ }
1050
1090
  const chart = args.chart === 'ziwei' ? 'ziwei' : 'bazi';
1051
1091
  // 性别决定双方大运/大限顺逆,两边都不能用默认值
1052
- const sideA = {
1053
- ...pa,
1054
- gender: requireGender(args.gender, '你(主方)'),
1055
- trueSolar: args.trueSolar,
1056
- lateZi: args.lateZi,
1057
- dst: args.dst !== false,
1058
- address: typeof args.address === 'string' ? args.address : undefined,
1059
- longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
1060
- geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
1061
- };
1062
- const sideB = {
1063
- ...pb,
1064
- gender: requireGender(partner.gender, '对方'),
1065
- trueSolar: partner.trueSolar,
1066
- lateZi: partner.lateZi,
1067
- dst: partner.dst !== false,
1068
- address: typeof partner.address === 'string' ? partner.address : undefined,
1069
- longitude: typeof partner.longitude === 'number' ? partner.longitude : undefined,
1070
- geo: partner.geo && typeof partner.geo === 'object' ? partner.geo : undefined,
1092
+ const sideOf = (src, who) => {
1093
+ const dt = parseDateTime(src.datetime);
1094
+ return {
1095
+ ...dt,
1096
+ gender: requireGender(src.gender, who),
1097
+ trueSolar: src.trueSolar,
1098
+ lateZi: src.lateZi,
1099
+ dst: src.dst !== false,
1100
+ address: typeof src.address === 'string' ? src.address : undefined,
1101
+ longitude: typeof src.longitude === 'number' ? src.longitude : undefined,
1102
+ geo: src.geo && typeof src.geo === 'object' ? src.geo : undefined,
1103
+ };
1071
1104
  };
1072
- const result = chart === 'ziwei' ? buildZiweiHepan({ a: sideA, b: sideB }) : buildHepan({ a: sideA, b: sideB });
1105
+ const sideA = sideOf(args, '你(主方)');
1106
+ const sideB = sideOf(partner, '对方');
1107
+ const build = (x, y) => (chart === 'ziwei' ? buildZiweiHepan({ a: x, b: y }) : buildHepan({ a: x, b: y }));
1108
+ const result = build(sideA, sideB);
1109
+ if (partner2) {
1110
+ const sideC = sideOf(partner2, '第三方');
1111
+ result.triple = tripleOf([
1112
+ { key: 'a-b', a: '你', b: '对方', result },
1113
+ { key: 'a-c', a: '你', b: '第三方', result: build(sideA, sideC) },
1114
+ { key: 'b-c', a: '对方', b: '第三方', result: build(sideB, sideC) },
1115
+ ]);
1116
+ }
1073
1117
  return { ...result, summary: hepanText(result) };
1074
1118
  }
1075
1119
  if (system === 'meihua') {
@@ -1156,16 +1200,23 @@ function computeRaw(args = {}) {
1156
1200
  return { ...result, summary: almanacText(result) };
1157
1201
  }
1158
1202
  if (system === 'zeri') {
1159
- // startDate 必须显式:择日要「从哪天起看」,缺了不能拿本机今天静默代填
1160
- if (typeof args.startDate !== 'string' || !args.startDate.trim()) {
1161
- throw fail('E_INPUT', '择日需要 startDate(公历 YYYY-MM-DD,起算日);可另给 days(1–60,默认 30)与 shengXiao(当事人属相)');
1203
+ const candMode = args.candidates !== undefined && args.candidates !== null;
1204
+ // startDate 必须显式:择日要「从哪天起看」,缺了不能拿本机今天静默代填;候选日模式改用 candidates
1205
+ if (!candMode && (typeof args.startDate !== 'string' || !args.startDate.trim())) {
1206
+ throw fail('E_INPUT', '择日需要 startDate(公历 YYYY-MM-DD,起算日);可另给 days(1–60,默认 30)与 shengXiao(当事人属相)。只想比几天,改给 candidates: ["2026-12-01","2026-12-08"]');
1207
+ }
1208
+ let result;
1209
+ try {
1210
+ result = buildZeri({
1211
+ task: args.task,
1212
+ startDate: args.startDate,
1213
+ days: args.days,
1214
+ shengXiao: args.shengXiao,
1215
+ candidates: args.candidates,
1216
+ });
1217
+ } catch (e) {
1218
+ throw fail('E_INPUT', (e && e.message) || String(e));
1162
1219
  }
1163
- const result = buildZeri({
1164
- task: args.task,
1165
- startDate: args.startDate,
1166
- days: args.days,
1167
- shengXiao: args.shengXiao,
1168
- });
1169
1220
  return { ...result, summary: zeriText(result) };
1170
1221
  }
1171
1222
  if (system === 'chenggu') {
@@ -1355,16 +1406,27 @@ function almanacText(r) {
1355
1406
 
1356
1407
  /** 择日文本 */
1357
1408
  function zeriText(r) {
1358
- const head = `【择日·${r.meta.task}】${r.meta.startDate} 起 ${r.meta.days} 天${r.meta.shengXiao ? ` 当事人属${r.meta.shengXiao}` : '(未给属相,未做冲煞排除)'}`;
1359
- const top = r.days.slice().sort((a, b) => b.score - a.score).slice(0, 5)
1409
+ const cand = r.compare && r.compare.mode === 'candidates';
1410
+ const head = cand
1411
+ ? `【择日·${r.meta.task}】候选 ${r.meta.days} 天:${r.meta.candidates.join('、')}${r.meta.shengXiao ? ` 当事人属${r.meta.shengXiao}` : '(未给属相,未做冲煞排除)'}`
1412
+ : `【择日·${r.meta.task}】${r.meta.startDate} 起 ${r.meta.days} 天${r.meta.shengXiao ? ` 当事人属${r.meta.shengXiao}` : '(未给属相,未做冲煞排除)'}`;
1413
+ const top = r.days.slice().sort((a, b) => b.score - a.score).slice(0, cand ? 12 : 5)
1360
1414
  .map((d) => `· ${d.date} ${d.weekday} ${d.verdict}(${d.score}分) 宜${d.yi.slice(0, 3).join('/')} ${d.reasons.join(';') || '无特别加减分'}`);
1415
+ const bestLine = cand
1416
+ ? (r.compare.best
1417
+ ? `这一组里最优:${r.compare.best.date}(${r.compare.best.score}分,${r.compare.best.verdict})`
1418
+ + (r.compare.best.ties.length > 1 ? `;同分:${r.compare.best.ties.filter((d) => d !== r.compare.best.date).join('、')}` : '')
1419
+ : '这一组都因冲属相被排除,没有可用的日子')
1420
+ : `推荐日:${r.best.length ? r.best.join('、') : '本期无(都不够推荐分)'}`;
1361
1421
  return [
1362
1422
  head,
1363
- `推荐日:${r.best.length ? r.best.join('、') : '本期无(都不够推荐分)'}`,
1423
+ bestLine,
1424
+ `推荐日:${r.best.length ? r.best.join('、') : '无'}`,
1364
1425
  `可用日:${r.usable.length ? r.usable.join('、') : '无'}`,
1365
1426
  `回避日:${r.avoid.length ? r.avoid.slice(0, 6).join('、') : '无'}`,
1366
- '逐日排序:',
1427
+ cand ? '候选日逐日:' : '逐日排序:',
1367
1428
  ...top,
1429
+ ...(cand ? [`比较口径:${r.compare.note}`] : []),
1368
1430
  `口径:${r.meta.scope}`,
1369
1431
  ].join('\n');
1370
1432
  }
@@ -1614,7 +1676,11 @@ export function renderText(result) {
1614
1676
  + c.switches.map((s) => {
1615
1677
  if (s.error) return `· ${s.title}:未能对照(${s.error})`;
1616
1678
  if (!s.total) return `· ${s.title}:${s.currentLabel} → ${s.altLabel},本盘无变化`;
1617
- return `· ${s.title}:${s.currentLabel} → ${s.altLabel}\n ${s.summary}`;
1679
+ const changed = (Array.isArray(s.views) ? s.views : []).filter((v) => v.changed);
1680
+ const side = changed.length
1681
+ ? `\n 并排:${changed.map((v) => `${v.label} ${v.current} → ${v.alt}`).join(';')}`
1682
+ : '';
1683
+ return `· ${s.title}:${s.currentLabel} → ${s.altLabel}\n ${s.summary}${side}`;
1618
1684
  }).join('\n'));
1619
1685
  }
1620
1686
  if (result.followup) {
@@ -1648,6 +1714,8 @@ export { buildAlmanac, buildZeri, buildChenggu, buildXiaoliuren, buildStar, buil
1648
1714
  export { buildTrend, buildFengshui, buildName };
1649
1715
  export { buildTrace, traceMarkdown };
1650
1716
  export { encodeHandle, decodeHandle, resolveHandle };
1717
+ // C3 对话桥替代:深链编解码、表单还原、三档复制包(面板与独立页共用一份)
1718
+ export { askLink, parseAskLink, formOf, restNote, askPackages };
1651
1719
 
1652
1720
 
1653
1721
 
@@ -25,7 +25,8 @@ export const KNOWLEDGE = [
25
25
  system: 'bazi', dims: ['overall', 'career'],
26
26
  gist: '天干是露在外面的样子,地支是藏在地下的根。',
27
27
  detail: '同一个五行,透在天干叫「显」,扎在地支叫「有根」。只有天干没有地支,是看着有、使不上劲;只有地支不通天干,是有实力、不显眼。判断一个人能不能扛事,看的就是根有多深。',
28
- source: '《滴天髓》「天干得一比肩,不如得支中一墓库」一路的取象',
28
+ source: '《滴天髓·干支总论》「天全一气,不可使地德莫之载;地全三物,不可使天道莫之容」',
29
+ note: '常被署给《滴天髓》的「地支所藏之干,本静以待用,透出干头,则显其用矣。故干以通根为美,支以透出为贵」,出自《子平真诠》卷一论十干十二支的注文——滴天髓底本与沈孝瞻原文里都查不到这句,注文自己引的才是上面那句滴天髓。所以本卡引可核的原文,注文只作说明。',
29
30
  status: 'settled',
30
31
  },
31
32
  {
@@ -372,7 +373,7 @@ export const KNOWLEDGE = [
372
373
  detail: '进神退神本身只说明这一爻的向外趋势:用神化进为得力,忌神化进则为患加剧;用神化退是力衰,忌神化退反而是解脱。传统还要看它在本月令衰旺、有没有日辰生扶,空亡或受克时进者未必能进。本插件把进退与爻位标出来,再按月建五态给一档力重——得令则进得动、退得慢,失令则进得虚、退得实,月破压过一切;只给档位,不折成加权分数,吉凶仍落在用神与忌神那一层判。',
373
374
  source: '《增删卜易》进神退神章、用神章',
374
375
  status: 'variant',
375
- note: '土之进退次序各家说法不完全一致,有以丑→辰→未→戌为进、也有只取辰戌丑未相对者;本插件按丑辰未戌成环处理,并用《增删卜易》卦例中「未土化戌土为进」「戌土化未土为退」对拍。',
376
+ note: '土之进退次序各家说法不完全一致,有以丑→辰→未→戌为进、也有只取辰戌丑未相对者;本插件按丑辰未戌成环处理,并用《增删卜易》卦例中「未土化戌土为进」「戌土化未土为退」对拍。喜忌分档的出处是《增删卜易》卷二·進神退神章第二十九:「所喜者宜於化進,所忌者宜化退神」;把「喜/忌」具体落到用神/忌神两档,是本插件的引申。',
376
377
  },
377
378
 
378
379
  // ── 梅花 / 六壬 / 奇门 / 塔罗 ───────────────────────────
package/engine/trace.js CHANGED
@@ -56,7 +56,7 @@ function liuyaoTrace(r, opts = {}) {
56
56
  ['变卦', bian ? `${text(bian.name)}(上${text(bian.upper)}·下${text(bian.lower)})` : '无'],
57
57
  ],
58
58
  lines.map((l) => `${l.position}爻 ${text(l.ganzhi)}${text(l.element)}`).join('|'),
59
- '京房纳甲诀;engine/liuyao.js · NAJIA',
59
+ '京房纳甲(八宫纳甲表);实现:engine/liuyao.js · NAJIA(规则表,本仓未录入古籍章页)',
60
60
  );
61
61
 
62
62
  const liuqin = step(
@@ -69,7 +69,7 @@ function liuyaoTrace(r, opts = {}) {
69
69
  ['日干起六神', `${text(r.dayGanZhi && r.dayGanZhi.stem)}日`],
70
70
  ],
71
71
  lines.map((l) => `${l.position}爻 ${text(l.liuqin)}(${text(l.element)})`).join('|'),
72
- '五行生克;engine/liuyao.js · liuqin()',
72
+ '五行生克定六亲;取用可对读《增删卜易》卷一·用神章第八;实现:engine/liuyao.js · liuqin()',
73
73
  );
74
74
 
75
75
  const shiying = step(
@@ -83,7 +83,7 @@ function liuyaoTrace(r, opts = {}) {
83
83
  ],
84
84
  `${text(ben.shi)}爻${shi ? ` ${text(shi.liuqin)}·${text(shi.ganzhi)}${text(shi.element)}` : ''}为世,`
85
85
  + `${text(ben.ying)}爻${ying ? ` ${text(ying.liuqin)}·${text(ying.ganzhi)}${text(ying.element)}` : ''}为应`,
86
- '八宫世应诀;engine/liuyao.js · SHI_POSITION',
86
+ '八宫世应诀(世爻序与应爻隔三位);实现:engine/liuyao.js · SHI_POSITION(通行动诀,本仓未录入古籍章页)',
87
87
  );
88
88
 
89
89
  let fushen;
@@ -97,7 +97,7 @@ function liuyaoTrace(r, opts = {}) {
97
97
  '用神不上卦时,回本宫首卦的同位取伏神;飞伏之间只判生克与助压。',
98
98
  [['用神', ug && ug.ask ? ug.ask : '未取']],
99
99
  `${why};本层不适用。`,
100
- 'engine/liuyao.js · yongshenOf()',
100
+ '取伏之法参《增删卜易》卷二·飛伏神章第二十八;实现:engine/liuyao.js · yongshenOf()',
101
101
  );
102
102
  } else if (ug.missing) {
103
103
  const hidden = (ug.fushen || []).map((f) => {
@@ -117,7 +117,7 @@ function liuyaoTrace(r, opts = {}) {
117
117
  ['月建 / 日辰', `${text(month && month.branch)} / ${text(day && day.branch)}`],
118
118
  ],
119
119
  `${hidden || '本卦无此六亲,首卦同位也未取到伏神'}${access ? `;出伏:${access}` : ''}`,
120
- '本宫首卦取伏;engine/liuyao.js · fushenAccess()',
120
+ '《增删卜易》卷二·飛伏神章第二十八(用神不現,尋得伏神而遇生扶有用);实现:engine/liuyao.js · fushenAccess()',
121
121
  );
122
122
  } else {
123
123
  fushen = step(
@@ -129,7 +129,7 @@ function liuyaoTrace(r, opts = {}) {
129
129
  ['在位', list((ug.found || []).map((l) => `${l.position}爻`))],
130
130
  ],
131
131
  `用神 ${ug.liuqin} 已在 ${list((ug.found || []).map((l) => `${l.position}爻`))},无需伏神。`,
132
- 'engine/liuyao.js · yongshenOf()',
132
+ '取伏之法参《增删卜易》卷二·飛伏神章第二十八;实现:engine/liuyao.js · yongshenOf()',
133
133
  );
134
134
  }
135
135
 
@@ -150,7 +150,7 @@ function liuyaoTrace(r, opts = {}) {
150
150
  useStrength ? `用神:${useStrength}${us.labels && us.labels.length ? `(${us.labels.join('、')})` : ''}` : '用神:未取,只看全卦',
151
151
  hs && hs.labels && hs.labels.length ? `全卦:${hs.labels.join('、')}` : '',
152
152
  ].filter(Boolean).join(';'),
153
- '月建主旺衰、日辰司生克;engine/liuyao.js · yongshenStrength / hexagramStrength',
153
+ '《增删卜易》卷一·月將章第十六、日辰章第十七;实现:engine/liuyao.js · yongshenStrength / hexagramStrength',
154
154
  );
155
155
 
156
156
  return [najia, liuqin, shiying, fushen, wangsheng];
@@ -179,7 +179,7 @@ function baziTrace(r) {
179
179
  ['虚透', list(parts.virtualHelpers, '无')],
180
180
  ],
181
181
  `${text(s.level)}(${text(s.score)} 分):${text(s.detail)}`,
182
- 'engine/bazi.js · 旺衰量化(月令权重 + 藏干通根,三会/三合/六合折根 + 天干虚透减半)',
182
+ '本仓量化规则(月令权重 3/2/1/0.5/0、藏干通根、三会/三合/六合折根、虚透减半;非古籍定数);实现:engine/bazi.js · 旺衰量化',
183
183
  );
184
184
 
185
185
  const primaryName = text(frame.primaryName || (primary && primary.name), '未取格局');
@@ -211,7 +211,7 @@ function baziTrace(r) {
211
211
  ['调候', tiaohou && tiaohou.kind ? `${text(tiaohou.kind)}${need && need.element ? ` → ${text(need.element)}` : ''}` : '—'],
212
212
  ],
213
213
  `${resultParts}`,
214
- '子平真诠 · 月令取格;engine/geju.js · yongshenOf()',
214
+ '《子平真诠》八、论用神(专求月令);调候参《穷通宝鉴》五行总论;实现:engine/geju.js · yongshenOf()',
215
215
  );
216
216
 
217
217
  const p = r.palaces && typeof r.palaces === 'object' ? r.palaces : {};
@@ -228,7 +228,7 @@ function baziTrace(r) {
228
228
  ['命宫 / 身宫', `${text(p.minggong && p.minggong.ganzhi)} / ${text(p.shengong && p.shengong.ganzhi)}`],
229
229
  ],
230
230
  `胎元 ${text(p.taiyuan.ganzhi)};${p.taixi ? `胎息 ${text(p.taixi.ganzhi)};` : ''}命宫 ${text(p.minggong && p.minggong.ganzhi)};身宫 ${text(p.shengong && p.shengong.ganzhi)}。四宫是辅助坐标,不单独折吉凶。`,
231
- 'Tyme 四宫口径;engine/bazi.js · palaces',
231
+ 'Tyme 1.5.4 四宫口径(与 ziwei.pub 704 组日期对拍);本仓未直接照录古籍章页;实现:engine/bazi.js · palaces',
232
232
  ) : null;
233
233
 
234
234
  return [strength, geju, ...(palaces ? [palaces] : [])];
@@ -251,7 +251,7 @@ function ziweiTrace(r) {
251
251
  ['命中数', `${patterns.length} / ${total}`],
252
252
  ],
253
253
  patterns.length ? `命中 ${patterns.length} 条:${hit}` : '未命中已收录的格局条件(不表示命盘无结构,只是不在已收录清单内)。',
254
- '紫微斗数全书 · 格局条件;engine/ziwei.js · detectPatterns()',
254
+ '《紫微斗数全书》卷三·论人命入格、谈星要论;实现:engine/ziwei.js · detectPatterns()',
255
255
  )];
256
256
  }
257
257
 
@@ -277,7 +277,7 @@ function qimenTrace(r, opts = {}) {
277
277
  ['空亡 / 驿马', `${list(r.kongWang)} / ${text(ma.zhi)}${ma.gong ? `(${ma.gong}宫)` : ''}`],
278
278
  ],
279
279
  palaces.map((p) => `${p.palace}宫 天${text(p.tianPan)}·${text(p.star)}·${text(p.gate)}·${text(p.god)}`).join('|') || '无九宫数据',
280
- 'engine/qimen.js · buildQimen()',
280
+ '《奇门遁甲统宗》卷一·奇门秘诀总赋(阴阳二遁、五日换元、超神接气);实现:engine/qimen.js · buildQimen()',
281
281
  );
282
282
 
283
283
  const ys = qimenYongshen(r, opts.direction);
@@ -298,7 +298,7 @@ function qimenTrace(r, opts = {}) {
298
298
  ? `${ys.note};${found.map((x) => `${x.key} 落 ${x.palace.palace}宫(${text(x.palace.star)}·${text(x.palace.gate)}·${text(x.palace.god)})`).join(',')}`
299
299
  + (missing.length ? `;${missing.join('、')} 未上盘` : '')
300
300
  : `${ys.label}的用神均未上盘,属未决项;先按值符落宫参照。`,
301
- '奇门用神取象;engine/qimen-interpret.js · qimenYongshen()',
301
+ '本仓用神表(按所问之事取用,非古籍原文);实现:engine/qimen-interpret.js · qimenYongshen()',
302
302
  );
303
303
 
304
304
  const order = ['旺', '相', '休', '囚', '死'];
@@ -315,7 +315,7 @@ function qimenTrace(r, opts = {}) {
315
315
  ['天盘干', stateLine(states.stem)],
316
316
  ],
317
317
  `门:${stateLine(states.gate)};星:${stateLine(states.star)};天盘干:${stateLine(states.stem)}`,
318
- '月令五行五态;engine/qimen.js · groupByState()',
318
+ '《奇门遁甲统宗》卷三(旺相休囚);实现:engine/qimen.js · groupByState()',
319
319
  );
320
320
 
321
321
  const forced = [];
@@ -354,7 +354,7 @@ function qimenTrace(r, opts = {}) {
354
354
  ['空亡', kongCells.join('、') || '无'],
355
355
  ],
356
356
  `门迫 ${forced.length} 处、宫制门 ${controlled.length} 处;天盘干与宫:${stemLine};击刑 ${jiXing.length} 处、三奇入墓 ${ruMu.length} 处、六仪入墓 ${liuYiRuMu.length} 处、落空 ${kongCells.length} 宫。十干克应:${ganying || '—'}。`,
357
- '门迫/宫制门;engine/qimen.js · gatePalaceRelation() / stemPalaceRelation();十干克应 engine/qimen-ganying.js',
357
+ '《奇门遁甲统宗》卷二(八门迫制)、卷十一·玄机赋(中)(六仪击刑、三奇入墓);十干克应为本仓表;实现:engine/qimen.js · gatePalaceRelation() / stemPalaceRelation()、engine/qimen-ganying.js',
358
358
  );
359
359
 
360
360
  return [facts, yongshen, wangshuai, relations];
@@ -380,7 +380,7 @@ function daliurenTrace(r) {
380
380
  ['昼夜 / 贵人', `${text(r.dayNight)} / ${text(gr.zhi)}(${gr.reverse ? '逆布' : '顺布'})`],
381
381
  ],
382
382
  pan.map((p) => `${p.di}→${p.tian}${p.jiang ? `(${text(p.jiang)})` : ''}`).join('|') || '无天地盘数据',
383
- '大六壬月将加时;engine/daliuren.js · tianDiPan()',
383
+ '《大六壬大全》卷二·神将释(月将为十二支神,登明为首逆布);加时布盘属通行口诀,未标卷次;实现:engine/daliuren.js · tianDiPan()',
384
384
  );
385
385
 
386
386
  const sike = step(
@@ -393,7 +393,7 @@ function daliurenTrace(r) {
393
393
  ['四课', ke.map((k) => `${k.position}课 ${text(k.shang)}上${text(k.xia)}下`).join(';') || '—'],
394
394
  ],
395
395
  ke.map((k) => `${k.position}课 ${text(k.shang)}上${text(k.xia)}下${k.jiang ? `·${text(k.jiang)}` : ''}:${text(k.relation)}`).join(';') || '无四课数据',
396
- 'engine/daliuren.js · siKe()',
396
+ '《大六壬大全》卷一·起例(十干寄宫);四课余下三课按通行口诀;实现:engine/daliuren.js · siKe()',
397
397
  );
398
398
 
399
399
  const chuanLine = (c) => (c
@@ -410,7 +410,7 @@ function daliurenTrace(r) {
410
410
  ['末传', chuanLine(sc.mo)],
411
411
  ],
412
412
  `${chuanLine(sc.chu)} → ${chuanLine(sc.zhong)} → ${chuanLine(sc.mo)}`,
413
- 'engine/daliuren.js · chuan()',
413
+ '《大六壬大全》卷一·起例(贼克法为首法,比用、涉害、遥克、昴星、八专、伏吟、返吟依次列后);实现:engine/daliuren.js · chuan()',
414
414
  );
415
415
 
416
416
  return [tiandipan, sike, sanchuan];
package/engine/zeri.js CHANGED
@@ -81,23 +81,68 @@ const VERDICT = (score, ctx) => {
81
81
  * @param {{task:string, startDate:string, days?:number, shengXiao?:string}} input
82
82
  * startDate 必填(公历 YYYY-MM-DD),避免引擎在无时钟环境下静默取「今天」。
83
83
  */
84
+ /** 严格解析公历日期:2 月 30 日这类不存在的日子直接判非法,不滚到下个月 */
85
+ function parseYmd(raw) {
86
+ const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(String(raw || '').trim());
87
+ if (!m) return null;
88
+ const year = Number(m[1]);
89
+ const month = Number(m[2]);
90
+ const day = Number(m[3]);
91
+ if (month < 1 || month > 12 || day < 1) return null;
92
+ const back = fromJDN(toJDN(year, month, day));
93
+ return back.year === year && back.month === month && back.day === day ? { year, month, day } : null;
94
+ }
95
+
96
+ const ymd = (p) => `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
97
+
98
+ /**
99
+ * 择日主函数。
100
+ * @param {{task:string, startDate:string, days?:number, shengXiao?:string,
101
+ * candidates?:string[]}} input
102
+ * startDate 必填(公历 YYYY-MM-DD),避免引擎在无时钟环境下静默取「今天」。
103
+ * candidates 给了就只比这几个候选日(2–12 个,去重、保持输入顺序),startDate 与 days 不参与。
104
+ */
84
105
  export function buildZeri(input) {
85
106
  const task = input.task;
86
107
  if (!TASKS[task]) throw new Error(`不支持的事项「${task}」。支持:${Object.keys(TASKS).join('、')}`);
87
- const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(String(input.startDate || '').trim());
88
- if (!m) throw new Error('择日需要 startDate(公历 YYYY-MM-DD)——请向用户询问起算日,不要代填');
89
- const start = { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) };
90
- const days = Math.max(1, Math.min(60, Number.isInteger(input.days) ? input.days : 30));
91
108
  const shengXiaoRaw = typeof input.shengXiao === 'string' ? input.shengXiao.trim() : '';
92
109
  const shengXiao = shengXiaoRaw || null;
93
110
  if (shengXiao && !SHENGXIAO.includes(shengXiao)) throw new Error(`shengXiao 须为生肖名:${SHENGXIAO.join('、')}`);
94
111
 
112
+ // 候选日模式:只比用户给的这几天,不再按区间扫
113
+ let candidates = null;
114
+ if (input.candidates !== undefined && input.candidates !== null) {
115
+ if (!Array.isArray(input.candidates)) {
116
+ throw new Error('candidates 须为日期数组,例如 ["2026-12-01","2026-12-08","2026-12-15"]');
117
+ }
118
+ if (input.candidates.length < 2) throw new Error('candidates 至少给 2 个候选日;只想看一天请用 startDate + days: 1');
119
+ if (input.candidates.length > 12) throw new Error('candidates 最多 12 个候选日;更多请用 startDate + days 扫区间');
120
+ const seen = new Set();
121
+ candidates = [];
122
+ for (const raw of input.candidates) {
123
+ const p = parseYmd(raw);
124
+ if (!p) throw new Error(`候选日不是真实存在的公历日期:「${raw}」,请用 2026-12-01 这种格式`);
125
+ const key = ymd(p);
126
+ if (seen.has(key)) continue;
127
+ seen.add(key);
128
+ candidates.push(p);
129
+ }
130
+ if (candidates.length < 2) throw new Error('候选日去重后不足 2 天;至少要两个不同的日子才谈得上比较');
131
+ }
132
+
133
+ let start = null;
134
+ let days = 0;
135
+ if (!candidates) {
136
+ const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(String(input.startDate || '').trim());
137
+ if (!m) throw new Error('择日需要 startDate(公历 YYYY-MM-DD)——请向用户询问起算日,不要代填');
138
+ start = { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) };
139
+ days = Math.max(1, Math.min(60, Number.isInteger(input.days) ? input.days : 30));
140
+ }
141
+
95
142
  const spec = TASKS[task];
96
143
  const zhixingSet = ZHIXING_FAVOR[spec.category];
97
- const startJdn = toJDN(start.year, start.month, start.day);
98
- const list = [];
99
- for (let i = 0; i < days; i++) {
100
- const p = fromJDN(startJdn + i);
144
+
145
+ const makeRow = (p) => {
101
146
  const a = buildAlmanac(p);
102
147
  let score = 0;
103
148
  const reasons = [];
@@ -114,7 +159,7 @@ export function buildZeri(input) {
114
159
  if (a.zhiXing === '危' && spec.category !== 'travel' && spec.category !== 'worship') { score -= 1; reasons.push('危日(-1)'); }
115
160
 
116
161
  const v = VERDICT(score, { chongSelf, jiHit });
117
- list.push({
162
+ return {
118
163
  date: a.meta.date,
119
164
  weekday: a.meta.weekday,
120
165
  lunar: a.lunar.text,
@@ -128,17 +173,47 @@ export function buildZeri(input) {
128
173
  verdict: v.verdict,
129
174
  verdictText: v.text,
130
175
  ...(v.excluded ? { excluded: true } : {}),
131
- });
132
- }
176
+ };
177
+ };
178
+
179
+ const list = candidates
180
+ ? candidates.map(makeRow)
181
+ : Array.from({ length: days }, (_, i) => makeRow(fromJDN(toJDN(start.year, start.month, start.day) + i)));
133
182
 
134
183
  const ranked = list.slice().sort((x, y) => y.score - x.score || (x.date < y.date ? -1 : 1));
184
+ const startDate = candidates
185
+ ? candidates.slice().sort((x, y) => (ymd(x) < ymd(y) ? -1 : 1)).map(ymd)[0]
186
+ : ymd(start);
187
+
188
+ // 候选日比较:只在这几天里比,给出最高分和同分者;不换算成「好多少」
189
+ let compare = null;
190
+ if (candidates) {
191
+ const pool = ranked.filter((d) => !d.excluded);
192
+ const top = pool[0] || null;
193
+ const scores = list.map((d) => d.score);
194
+ compare = {
195
+ mode: 'candidates',
196
+ count: list.length,
197
+ best: top ? {
198
+ date: top.date,
199
+ score: top.score,
200
+ verdict: top.verdict,
201
+ ties: pool.filter((d) => d.score === top.score).map((d) => d.date),
202
+ } : null,
203
+ spread: Math.max(...scores) - Math.min(...scores),
204
+ note: '候选日只在这几天里比:分数来自建除宜忌与冲煞,胜出只表示更合所问事项,不代表「好多少」。',
205
+ };
206
+ }
207
+
135
208
  return {
136
209
  system: 'zeri',
137
210
  meta: {
138
211
  task,
139
- startDate: `${start.year}-${String(start.month).padStart(2, '0')}-${String(start.day).padStart(2, '0')}`,
140
- days,
212
+ mode: candidates ? 'candidates' : 'range',
213
+ startDate,
214
+ days: list.length,
141
215
  shengXiao,
216
+ ...(candidates ? { candidates: candidates.map(ymd) } : {}),
142
217
  scope: shengXiao
143
218
  ? `宜忌按建除简表;已给当事人属相(${shengXiao}),凡当日冲该属相径直排除。评分仅用于排序引导。`
144
219
  : '宜忌按建除简表;未给当事人属相,未做冲属相排除。评分仅用于排序引导。',
@@ -147,6 +222,7 @@ export function buildZeri(input) {
147
222
  usable: ranked.filter((d) => d.verdict === '可用' && !d.excluded).slice(0, 5).map((d) => d.date),
148
223
  avoid: list.filter((d) => d.verdict === '回避').map((d) => `${d.date}(${d.reasons.join(';') || '综合减分'})`),
149
224
  days: list,
225
+ ...(compare ? { compare } : {}),
150
226
  excludedCount: list.filter((d) => d.excluded).length,
151
227
  sources: ['宜忌:建除十二神通行简表', '建除适配与冲煞:传统通行口径', '排序:宜忌加减分 + 属相排除(确定性)'],
152
228
  disclaimer: '择日为传统黄历口径的本地计算,属民俗参考;评分只用于排序,不构成择日结论。',
package/index.js CHANGED
@@ -230,11 +230,16 @@ export function apply(ctx) {
230
230
  },
231
231
  startDate: {
232
232
  type: 'string',
233
- description: '仅 zeri 使用:起算日(公历 YYYY-MM-DD)。省略则由宿主补今天。',
233
+ description: '仅 zeri 使用:起算日(公历 YYYY-MM-DD)。省略则由宿主补今天;给了 candidates 时不需要。',
234
234
  },
235
235
  days: {
236
236
  type: 'number',
237
- description: '仅 zeri 使用:往后考察天数,1–60,默认 30。',
237
+ description: '仅 zeri 使用:往后考察天数,1–60,默认 30;给了 candidates 时忽略。',
238
+ },
239
+ candidates: {
240
+ type: 'array',
241
+ items: { type: 'string' },
242
+ description: '仅 zeri 使用:候选日比较(2–12 个真实公历日期,如 ["2026-12-01","2026-12-08"])。给了就只比这几天,结果多一个 compare(这一组最优、同分者、分差)与逐日并排;不替代 startDate 扫区间。',
238
243
  },
239
244
  shengXiao: {
240
245
  type: 'string',
@@ -330,6 +335,17 @@ export function apply(ctx) {
330
335
  },
331
336
  required: ['datetime', 'gender'],
332
337
  },
338
+ partner2: {
339
+ type: 'object',
340
+ description: '三方合盘(可选):第三个人的出生信息,字段与 partner 相同。给了就多回 triple——你×对方、你×第三方、对方×第三方三对分数并列,不合成「三人总分」。仅 hepan 使用;null 与省略等价,不要传 null。',
341
+ properties: {
342
+ datetime: { type: 'string', description: '第三方公历本地时间,如 1988-09-09T08:00' },
343
+ gender: { type: 'string', enum: ['male', 'female'], description: '第三方性别' },
344
+ address: { type: 'string', description: '第三方出生地(省/市即可)' },
345
+ trueSolar: { type: 'boolean', description: '第三方是否启用真太阳时' },
346
+ },
347
+ required: ['datetime', 'gender'],
348
+ },
333
349
  mode: {
334
350
  type: 'string',
335
351
  enum: ['time', 'number'],