dsh-metaphysics 0.61.2 → 0.62.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/README.md +3 -3
  3. package/client.almanac.js +74 -69
  4. package/client.bazi.js +129 -124
  5. package/client.chenggu.js +75 -70
  6. package/client.convert.js +66 -61
  7. package/client.daliuren.js +129 -124
  8. package/client.fengshui.js +57 -52
  9. package/client.hepan.js +128 -123
  10. package/client.js +2 -2
  11. package/client.liuyao.js +127 -122
  12. package/client.mbti.js +66 -61
  13. package/client.name.js +70 -65
  14. package/client.numerology.js +66 -61
  15. package/client.qimen.js +126 -121
  16. package/client.star.js +72 -67
  17. package/client.tarot.js +123 -118
  18. package/client.trend.js +119 -114
  19. package/client.xiaoliuren.js +84 -79
  20. package/client.zeri.js +66 -61
  21. package/client.ziwei.js +129 -124
  22. package/engine/bazi-words.js +3 -0
  23. package/engine/bazi.js +29 -12
  24. package/engine/calendar.js +22 -1
  25. package/engine/chenggu.js +20 -6
  26. package/engine/convert.js +18 -9
  27. package/engine/daliuren.js +87 -18
  28. package/engine/datetime.js +73 -0
  29. package/engine/diff.js +1 -1
  30. package/engine/dst.js +0 -7
  31. package/engine/fengshui.js +13 -1
  32. package/engine/fingerprint.js +2 -1
  33. package/engine/followup.js +33 -3
  34. package/engine/geo.js +58 -12
  35. package/engine/handle.js +53 -1
  36. package/engine/index.js +129 -66
  37. package/engine/interpret-knowledge.js +3 -3
  38. package/engine/liuyao.js +27 -4
  39. package/engine/lunar.js +0 -7
  40. package/engine/meihua.js +27 -16
  41. package/engine/name.js +12 -6
  42. package/engine/numerology.js +2 -1
  43. package/engine/qimen-interpret.js +28 -3
  44. package/engine/qimen-prompt.js +23 -17
  45. package/engine/qimen.js +23 -12
  46. package/engine/rectify.js +47 -6
  47. package/engine/star.js +3 -2
  48. package/engine/tarot.js +24 -6
  49. package/engine/tiaohou.js +17 -10
  50. package/engine/trend.js +11 -2
  51. package/engine/validate.js +30 -0
  52. package/engine/xiaoliuren.js +28 -7
  53. package/engine/yingqi.js +4 -9
  54. package/engine/zeri.js +19 -21
  55. package/engine/ziwei-hepan.js +3 -1
  56. package/engine/ziwei-words.js +2 -1
  57. package/engine/ziwei.js +15 -10
  58. package/index.js +99 -10
  59. package/package.json +1 -1
  60. package//345/221/275/347/220/206/346/216/222/347/233/230.html +12049 -11428
package/engine/liuyao.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // 六爻(纳甲)引擎:摇卦、装卦、六亲、六神、世应、变卦
2
2
  import { STEMS, BRANCHES, toJDN, ganzhiName, dayGanzhiIndex, sexagenaryIndex, xunKongOf } from './calendar.js';
3
3
  import { jingText } from './yijing-text.js';
4
+ import { fail } from './fail.js';
5
+ import { assertObject } from './validate.js';
4
6
 
5
7
  // 八卦三位(自下而上,1 = 阳)
6
8
  const TRIGRAM_BITS = {
@@ -117,6 +119,7 @@ function hexBranches(hex) {
117
119
 
118
120
  /** 单个卦的卦体属性:是否六冲卦 / 六合卦,及配对明细 */
119
121
  export function hexBody(hex) {
122
+ assertObject(hex, 'hexBody 的卦对象', '取 buildLiuyao 结果里的 ben / bian(含 name、upper、lower)');
120
123
  const { inner, outer, all } = hexBranches(hex);
121
124
  const chongPairs = pairThirds(all, ZHI_CHONG_PAIRS);
122
125
  const hePairs = pairThirds(all, ZHI_HE_PAIRS);
@@ -140,6 +143,8 @@ export function hexBody(hex) {
140
143
  * @param {number[]} [moving] 动爻位置(1~6)
141
144
  */
142
145
  export function bodyRelations(ben, bian = null, moving = []) {
146
+ assertObject(ben, 'bodyRelations 的本卦', 'hexagramFromBits / buildLiuyao 得到的卦对象(含 name、upper、lower);静卦时 bian 传 null');
147
+ if (bian !== null && bian !== undefined) assertObject(bian, 'bodyRelations 的变卦', '卦对象或 null(静卦传 null)');
143
148
  const b = hexBody(ben);
144
149
  const out = {
145
150
  ben: { name: b.name, chong: b.chong, he: b.he, chongPairs: b.chongPairs, hePairs: b.hePairs, inner: b.inner, outer: b.outer },
@@ -262,6 +267,10 @@ export const HEXAGRAM_COUNT = HEXAGRAM_INDEX.size;
262
267
 
263
268
  /** 由六爻(自下而上,1 = 阳)求卦 */
264
269
  export function hexagramFromBits(bits) {
270
+ // 爻数不对以前静默返回 undefined(HEXAGRAM_INDEX.get 落空),调用方拿着 undefined 继续读 .name 才炸
271
+ if (!Array.isArray(bits) || bits.length !== 6 || bits.some((b) => b !== 0 && b !== 1)) {
272
+ throw fail('E_INPUT', `hexagramFromBits 需要 6 个爻位(自下而上,每项 0 或 1),收到 ${Array.isArray(bits) ? `${bits.length} 项` : String(bits) === 'null' ? 'null' : typeof bits}`);
273
+ }
265
274
  const upper = TRIGRAM_BY_VALUE[bitsValue(bits.slice(3))];
266
275
  const lower = TRIGRAM_BY_VALUE[bitsValue(bits.slice(0, 3))];
267
276
  return HEXAGRAM_INDEX.get(`${upper}|${lower}`);
@@ -326,6 +335,10 @@ const sideOf = (position) => (position <= 3 ? 'inner' : 'outer');
326
335
  * @param {{direction?:string, gender?:string}} [opts]
327
336
  */
328
337
  export function yongshenOf(result, opts = {}) {
338
+ // opts 允许省略或传 null(都按默认口径),但显式给了别的东西就必须是对象:
339
+ // 以前 yongshenOf(r, null) 会一路走到读 opts.direction,抛裸 TypeError
340
+ if (opts === null || opts === undefined) opts = {};
341
+ assertObject(opts, 'yongshenOf 的 opts', '{ direction?, gender? };省略或 null 按默认口径');
329
342
  const r = result && typeof result === 'object' ? result : {};
330
343
  const ben = r.ben && typeof r.ben === 'object' ? r.ben : {};
331
344
  const lines = Array.isArray(ben.lines) ? ben.lines : [];
@@ -476,12 +489,22 @@ function isMoving(v) {
476
489
  * @param {object} input { lines?: number[] (自下而上, 6/7/8/9), dayGanZhi?: {stem, branch}, question?: string }
477
490
  */
478
491
  export function buildLiuyao(input = {}) {
479
- const rawLines = Array.isArray(input.lines) && input.lines.length === 6
480
- ? input.lines.slice()
481
- : tossLines(input.random);
492
+ if (!input || typeof input !== 'object') throw fail('E_INPUT', '六爻起卦参数不能为空:至少给 lines 或 seed');
493
+ // lines 给了但不是数组("7,8,9,6,7,8" / 789678 / {…})以前等同于「没给」:
494
+ // 用户以为摇的是自己记下的那一卦,实际拿到一张随机盘,而且看不出来。
495
+ if (input.lines !== undefined && input.lines !== null && !Array.isArray(input.lines)) {
496
+ throw fail('E_LINES', `lines 必须是数组:自下而上 6 个爻值(6 老阴 / 7 少阳 / 8 少阴 / 9 老阳),收到 ${typeof input.lines}`);
497
+ }
498
+ const given = Array.isArray(input.lines) ? input.lines : null;
499
+ // 给了 lines 就按给定的装卦;长度不对是输入错误,不能悄悄改成随机摇卦
500
+ // 码一律用 E_LINES:爻值这一族错误对外只有一个口径,宿主层不必再按文案二次判
501
+ if (given && given.length !== 6) {
502
+ throw fail('E_LINES', `lines 必须是 6 个爻值(自下而上),收到 ${given.length} 个`);
503
+ }
504
+ const rawLines = given ? given.slice() : tossLines(input.random);
482
505
 
483
506
  const invalid = rawLines.filter((v) => ![6, 7, 8, 9].includes(v));
484
- if (invalid.length) throw new Error(`爻值只能是 6/7/8/9,收到 ${JSON.stringify(rawLines)}`);
507
+ if (invalid.length) throw fail('E_LINES', `爻值只能是 6/7/8/9,收到 ${JSON.stringify(rawLines)}`);
485
508
 
486
509
  const bits = rawLines.map(lineToBit);
487
510
  const ben = hexagramFromBits(bits);
package/engine/lunar.js CHANGED
@@ -120,7 +120,6 @@ export function solarToLunar(year, month, day) {
120
120
  let num = 11;
121
121
  // 农历年以正月初一为界:月序越过「正」之后,岁次进一位
122
122
  let wrapped = false;
123
- let cursorStart = dayNumberOf(newMoonJd(k));
124
123
 
125
124
  for (let i = 1; i <= 15; i++) {
126
125
  const startDay = dayNumberOf(newMoonJd(k + i - 1));
@@ -149,17 +148,11 @@ export function solarToLunar(year, month, day) {
149
148
  num = (num % 12) + 1;
150
149
  if (num === 1) wrapped = true; // 已越过正月初一
151
150
  }
152
- cursorStart = endDay;
153
151
  }
154
152
 
155
153
  throw new Error(`农历换算失败:${year}-${month}-${day}`);
156
154
  }
157
155
 
158
- /** 农历月首的儒略日(供内部校验) */
159
- export function monthStartJd(year, month, day) {
160
- return toJDN(year, month, day);
161
- }
162
-
163
156
  export { SYNODIC };
164
157
 
165
158
 
package/engine/meihua.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // 梅花易数:时间/数字起卦、体用、互卦、变卦、错综卦
2
- import { BRANCHES } from './calendar.js';
2
+ import { BRANCHES, fromJDN, toJDN } from './calendar.js';
3
3
  import { solarToLunar } from './lunar.js';
4
4
  import { fail } from './fail.js';
5
+ import { parseDateTime } from './datetime.js';
6
+ import { assertShifted } from './validate.js';
5
7
  import { hexagramFromBits } from './liuyao.js';
6
8
  import { jingText } from './yijing-text.js';
7
9
 
@@ -45,6 +47,7 @@ function relation(bodyEl, useEl) {
45
47
  * upper/lower/changing: 数字(number 模式,可给两数由程序取余)
46
48
  */
47
49
  export function buildMeihua(input = {}) {
50
+ if (!input || typeof input !== 'object') throw fail('E_INPUT', '梅花起卦参数不能为空:时间起卦要 datetime,数字起卦要 upper / lower');
48
51
  const mode = input.mode === 'number' ? 'number' : 'time';
49
52
  let upperNum;
50
53
  let lowerNum;
@@ -57,13 +60,31 @@ export function buildMeihua(input = {}) {
57
60
  if (!Number.isFinite(a) || !Number.isFinite(b)) throw fail('E_INPUT', '数字起卦需要两个数字(upper / lower),例如 upper=3、lower=5');
58
61
  upperNum = mod8(a);
59
62
  lowerNum = mod8(b);
60
- moving = Number.isFinite(Number(input.changing)) && Number(input.changing) > 0
61
- ? mod6(Number(input.changing))
62
- : mod6(a + b);
63
+ if (input.changing !== undefined && input.changing !== null && input.changing !== '') {
64
+ // 以前 99 会被 mod 6 折成 3:用户指定动爻却得到另一爻,结果里看不出来
65
+ const ch = Number(input.changing);
66
+ if (!Number.isInteger(ch) || ch < 1 || ch > 6) {
67
+ throw fail('E_INPUT', `动爻 changing 必须是 1–6 的整数,收到「${input.changing}」`);
68
+ }
69
+ moving = ch;
70
+ } else {
71
+ moving = mod6(a + b);
72
+ }
63
73
  source = `数字起卦:${a} / ${b}`;
64
74
  } else {
65
75
  const dt = parseDateTime(input.datetime);
66
- const lunar = solarToLunar(dt.year, dt.month, dt.day);
76
+ // 晚子时换日与八字/六壬/奇门同口径:23:00–23:59 归次日,年月日电数才落在同一子时
77
+ const lateZi = input.lateZi !== false;
78
+ let sy = dt.year;
79
+ let sm = dt.month;
80
+ let sd = dt.day;
81
+ if (lateZi && dt.hour >= 23) {
82
+ const next = assertShifted(fromJDN(toJDN(sy, sm, sd) + 1), '梅花时间起卦');
83
+ sy = next.year;
84
+ sm = next.month;
85
+ sd = next.day;
86
+ }
87
+ const lunar = solarToLunar(sy, sm, sd);
67
88
  const yearBranch = ((lunar.lunarYear - 4) % 12 + 12) % 12;
68
89
  const hb = Math.floor(((dt.hour + 1) % 24) / 2);
69
90
  const sum1 = yearBranch + 1 + lunar.month + lunar.day;
@@ -71,7 +92,7 @@ export function buildMeihua(input = {}) {
71
92
  upperNum = mod8(sum1);
72
93
  lowerNum = mod8(sum2);
73
94
  moving = mod6(sum2);
74
- source = `时间起卦:${BRANCHES[yearBranch]}年${lunar.month}月${lunar.day}日${BRANCHES[hb]}时`;
95
+ source = `时间起卦:${BRANCHES[yearBranch]}年${lunar.month}月${lunar.day}日${BRANCHES[hb]}时${lateZi && dt.hour >= 23 ? '(晚子时按次日)' : ''}`;
75
96
  }
76
97
 
77
98
  const ben = hexName(upperNum, lowerNum);
@@ -127,14 +148,4 @@ export function buildMeihua(input = {}) {
127
148
  };
128
149
  }
129
150
 
130
- function parseDateTime(text) {
131
- if (typeof text !== 'string' || !text.trim()) throw fail('E_INPUT', '时间起卦需要 datetime,例如 2026-10-24T11:43');
132
- const m = text.trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T\s](\d{1,2}):(\d{1,2}))?$/);
133
- if (!m) throw fail('E_INPUT', `无法解析 datetime:「${text}」,请用 2026-10-24T11:43 这种格式`);
134
- return {
135
- year: Number(m[1]), month: Number(m[2]), day: Number(m[3]),
136
- hour: Number(m[4] ?? 0), minute: Number(m[5] ?? 0),
137
- };
138
- }
139
-
140
151
  export { TRI_NAME, TRI_ELEMENT, TRI_BITS };
package/engine/name.js CHANGED
@@ -4,7 +4,8 @@
4
4
  // 五格按康熙字典繁体笔画计(见 name-data.js 的口径说明),81 数理取通行本;
5
5
  // 音韵只做机械检查(同调、相邻同声母/同韵母、叠字),不做语义谐音判断。
6
6
  import { buildBazi } from './bazi.js';
7
- import { assertCivil } from './validate.js';
7
+ import { assertCivil, assertObject } from './validate.js';
8
+ import { fail } from './fail.js';
8
9
  import {
9
10
  READING, SURNAME_STROKES, SURNAME_READING, SURNAME_ALT_READING, DOUBLE_SURNAMES, SHULI_81,
10
11
  } from './name-data.js';
@@ -491,11 +492,12 @@ function charInfo(ch) {
491
492
 
492
493
  /** 评估一个名字(姓 + 名 1–2 字)。给了生辰才做喜用匹配。 */
493
494
  export function assessName(input) {
495
+ assertObject(input, 'assessName 的参数', '{ surname, givenName, year?, month?, day? }');
494
496
  const surname = String(input.surname || '').trim();
495
- if (!surname) throw new Error('缺少姓氏:请给 surname(如「李」)');
497
+ if (!surname) throw fail('E_INPUT', '缺少姓氏:请给 surname(如「李」)');
496
498
  const given = String(input.givenName || '').trim();
497
- if (!given) throw new Error('缺少名字:请给 givenName(1–2 个汉字,如「沐宸」)');
498
- if ([...given].length > 2) throw new Error('名请给 1–2 个汉字;复姓请把复姓整体写在 surname,名只留 1–2 字');
499
+ if (!given) throw fail('E_INPUT', '缺少名字:请给 givenName(1–2 个汉字,如「沐宸」)');
500
+ if ([...given].length > 2) throw fail('E_INPUT', '名请给 1–2 个汉字;复姓请把复姓整体写在 surname,名只留 1–2 字');
499
501
 
500
502
  let favorable = { list: [], basis: '' };
501
503
  let strength = null;
@@ -595,10 +597,11 @@ export function assessName(input) {
595
597
  * limit 1–20;同一字不重复;候选按「合喜用数 → 简洁 → 字库序」确定性排序。
596
598
  */
597
599
  export function generateNames(input) {
600
+ assertObject(input, 'generateNames 的参数', '{ surname, year, month, day, limit? }');
598
601
  const surname = String(input.surname || '').trim();
599
- if (!surname) throw new Error('缺少姓氏:请给 surname(如「李」)');
602
+ if (!surname) throw fail('E_INPUT', '缺少姓氏:请给 surname(如「李」)');
600
603
  if (!Number.isInteger(input.year) || !Number.isInteger(input.month) || !Number.isInteger(input.day)) {
601
- throw new Error('生成候选名需要完整出生年月日(公历):不给生辰无法判断喜用五行');
604
+ throw fail('E_INPUT', '生成候选名需要完整出生年月日(公历):不给生辰无法判断喜用五行');
602
605
  }
603
606
  assertCivil(input, 'birth');
604
607
  const gender = input.gender === 'female' ? 'female' : 'male';
@@ -720,6 +723,9 @@ export function generateNames(input) {
720
723
 
721
724
  /** 主函数:action='assess'(默认)或 'generate'。 */
722
725
  export function buildName(input) {
726
+ if (!input || typeof input !== 'object') {
727
+ throw fail('E_INPUT', '起名参数不能为空:评名要 surname / givenName,起名要 surname 与性别/生日');
728
+ }
723
729
  if (input.action === 'generate') return generateNames(input);
724
730
  return assessName(input);
725
731
  }
@@ -1,6 +1,7 @@
1
1
  // 生命数字(西方数字学):生命路径数 / 生日数 / 态度数,11、22、33 主数保留。
2
2
  // 纯本地确定性;与八字/紫微是两套不可互证的体系,输出里明确提示。
3
3
  import { daysInMonth } from './validate.js';
4
+ import { fail } from './fail.js';
4
5
 
5
6
  /** 1–9 与主数 11/22/33 的数义(白话提炼)。 */
6
7
  export const NUMBER_MEANING = {
@@ -38,7 +39,7 @@ export function buildNumerology(input) {
38
39
  const { year, month, day } = input || {};
39
40
  if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)
40
41
  || year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
41
- throw new Error('生命数字需要真实存在的公历出生日期(year 1900–2100、month、day)——请向用户询问,勿猜');
42
+ throw fail('E_INPUT', '生命数字需要真实存在的公历出生日期(year 1900–2100、month、day)——请向用户询问,勿猜');
42
43
  }
43
44
  const lifePath = reduceNumber(digitSum(year) + digitSum(month) + digitSum(day));
44
45
  const birthday = reduceNumber(day);
@@ -55,7 +55,16 @@ const REL_TEXT = {
55
55
  unknown: '五行缺失,关系未定',
56
56
  };
57
57
 
58
- const findPalace = (r, key) => r.palaces.find((p) => p.star === key || p.gate === key || p.god === key || p.palace === key) || null;
58
+ // 天芮在中宫寄坤时被统一改名「禽芮」,表里仍按通行名写;不折叠就永远定位不到。
59
+ // 驿马不在 palaces[].star 上,而是 r.ma.gong,单独走一条。
60
+ const STAR_ALIAS = { 天芮: '禽芮' };
61
+ const findPalace = (r, key) => {
62
+ if (key === '驿马') {
63
+ const gong = r.ma && r.ma.gong;
64
+ return gong ? (r.palaces.find((p) => p.palace === gong) || null) : null;
65
+ }
66
+ return r.palaces.find((p) => p.star === key || p.star === STAR_ALIAS[key] || p.gate === key || p.god === key || p.palace === key) || null;
67
+ };
59
68
  const cell = (p) => `${p.palace}宫(${p.star}·${p.gate}·${p.god})`;
60
69
 
61
70
  /** 按询问方向取用神及其落宫;解读与推导链共用这一张表,避免两处各写一份 */
@@ -111,7 +120,16 @@ export function interpretQimen(r, opts = {}) {
111
120
  // ③ 用神候选
112
121
  const yongList = ysInfo.rows.filter((x) => x.palace);
113
122
  if (!yongList.length) unresolved.push(`${ys.label}的用神未上盘`);
114
- if (yongList.length > 1) unresolved.push(`用神有 ${yongList.length} 个候选(${yongList.map((x) => x.key).join('、')})`);
123
+ else {
124
+ // 主用神缺位、只剩次级候选时不能装作没发生:原先主用神落空、死门仍能撑起 yongList,
125
+ // 「用神未上盘」的兜底永远不触发,健康解读就只剩一句「若以死门为用神」。
126
+ const missingPrimary = ys.primary.filter((k) => !ysInfo.rows.some((x) => x.key === k && x.palace));
127
+ if (missingPrimary.length) unresolved.push(`主用神${missingPrimary.join('、')}未上盘`);
128
+ }
129
+ // 「辅用神也在盘上」不算未决:YONG_SHEN 每个方向本就是「主用神 + 一两个辅用神」,
130
+ // 辅用神必然落宫,按候选数判未决等于 unresolved 恒非空,下面的倾向分支一次也没跑过
131
+ // (7 个方向全部退回「暂不给单一倾向」)。候选清单在 ④ 一节里逐条列出,不靠这里提醒。
132
+ const primaryYong = ysInfo.rows.find((x) => ys.primary.includes(x.key) && x.palace) || null;
115
133
  sections.push({
116
134
  key: 'yongshen',
117
135
  label: `用神候选(${ys.label})`,
@@ -121,7 +139,7 @@ export function interpretQimen(r, opts = {}) {
121
139
  });
122
140
 
123
141
  // ④ 两宫关系(用神宫 × 值符宫)
124
- const primary = yongList[0] || null;
142
+ const primary = primaryYong || yongList[0] || null;
125
143
  if (primary && zhiFuP) {
126
144
  if (primary.palace.palace === zhiFuP.palace) {
127
145
  sections.push({
@@ -201,6 +219,13 @@ export function interpretQimen(r, opts = {}) {
201
219
  label: '最终倾向',
202
220
  text: `暂不给单一倾向。未决项:${unresolved.join(';')}。先把用神定下来,再谈顺逆。`,
203
221
  });
222
+ } else if (!primary || !zhiFuP) {
223
+ // 顺逆要拿「用神宫 × 值符宫」对比,两处任一落不到具体单宫就没法给结论
224
+ sections.push({
225
+ key: 'tendency',
226
+ label: '最终倾向',
227
+ text: `暂不给单一倾向。${primary ? '值符落宫在本盘没有对应单宫,缺对照宫' : '本盘没有可用的用神落宫'},顺逆判断需要一个明确的对照,缺它就不硬凑。`,
228
+ });
204
229
  } else {
205
230
  const rel = wuxingRelation(primary.palace.palaceElement, zhiFuP.palaceElement);
206
231
  const tone = rel === '被生' || rel === '生' ? '偏向顺' : rel === '被克' ? '偏向滞' : '中性';
@@ -4,6 +4,8 @@
4
4
  // 模型拿到一屏数据后最省力的写法就是「开门临生门,吉」——这不是解读,是把符号念一遍。
5
5
  // 所以提示里把「取哪个符号、为什么取它、它落在哪、和谁什么关系、回到现实查什么」写成硬步骤,
6
6
  // 并要求先给人话结论,再给依据。文案与取法分开存,取法可测,文案可改。
7
+ import { xunShouLiuYi } from './qimen.js';
8
+
7
9
  export const QIMEN_PROMPT_VERSION = 'qimen-1';
8
10
 
9
11
  /** 九宫序号 → 方位与领域(读「事情落在哪个领域」时用,不直接当吉凶) */
@@ -122,11 +124,6 @@ const HARD_RULES = [
122
124
  '不确定就写不确定,并说明缺哪条信息才能确定。',
123
125
  ];
124
126
 
125
- const stemOf = (siZhu, key) => {
126
- const v = siZhu && siZhu[key];
127
- return v ? String(v).charAt(0) : '';
128
- };
129
-
130
127
  /** 天盘某干落在哪一宫(日干/时干/月干定位都走这里) */
131
128
  function palaceOfStem(q, stem) {
132
129
  if (!stem) return null;
@@ -138,6 +135,24 @@ function palaceOfStem(q, stem) {
138
135
  return c && c.tianPan === stem ? c : null;
139
136
  }
140
137
 
138
+ /** 某柱天干定宫:甲不上天盘,按本柱所在旬的旬首六仪代查,并在文案里标明寄仪 */
139
+ function palaceOfPillarStem(q, siZhu, key, label) {
140
+ const gz = siZhu && siZhu[key] ? String(siZhu[key]) : '';
141
+ const stem = gz.charAt(0);
142
+ if (!stem) return { symbol: `${label}`, palace: null, missing: `四柱里没有${label}` };
143
+ if (stem !== '甲') {
144
+ const p = palaceOfStem(q, stem);
145
+ return { symbol: `${label} ${stem}`, palace: p, missing: p ? '' : `${label}${stem}未现于天盘` };
146
+ }
147
+ const yi = xunShouLiuYi(gz);
148
+ const p = palaceOfStem(q, yi);
149
+ return {
150
+ symbol: `${label} 甲(寄${yi})`,
151
+ palace: p,
152
+ missing: p ? '' : `${label}甲寄${yi},${yi}未现于天盘`,
153
+ };
154
+ }
155
+
141
156
  function palaceOfGate(q, gate) {
142
157
  if (!gate) return null;
143
158
  return (q.palaces || []).find((p) => p.gate === gate) || null;
@@ -152,18 +167,9 @@ function palaceOfGod(q, god) {
152
167
  export function resolveSubject(q, sub) {
153
168
  const si = q.siZhu || {};
154
169
  const how = String(sub.how || '');
155
- if (how === 'dayStem') {
156
- const s = stemOf(si, 'day');
157
- return { symbol: `日干 ${s}`, palace: palaceOfStem(q, s), missing: s ? '' : '四柱里没有日干' };
158
- }
159
- if (how === 'hourStem') {
160
- const s = stemOf(si, 'time');
161
- return { symbol: `时干 ${s}`, palace: palaceOfStem(q, s), missing: s ? '' : '四柱里没有时干' };
162
- }
163
- if (how === 'monthStem') {
164
- const s = stemOf(si, 'month');
165
- return { symbol: `月干 ${s}`, palace: palaceOfStem(q, s), missing: s ? '' : '四柱里没有月干' };
166
- }
170
+ if (how === 'dayStem') return palaceOfPillarStem(q, si, 'day', '日干');
171
+ if (how === 'hourStem') return palaceOfPillarStem(q, si, 'time', '时干');
172
+ if (how === 'monthStem') return palaceOfPillarStem(q, si, 'month', '月干');
167
173
  if (how === 'zhiFu') {
168
174
  const g = q.zhiFu && q.zhiFu.gong;
169
175
  return { symbol: `值符(${(q.zhiFu && q.zhiFu.star) || ''})`, palace: (q.palaces || []).find((p) => p.palace === String(g)) || null, missing: g ? '' : '没有值符落宫' };
package/engine/qimen.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // 只把历法层换成本插件自己的 calendar/lunar 模块——节气、干支、农历都走同一套已验证的源码。
5
5
  import { termInstantJd, termWindow, jdToParts, partsToJd, formatJd, ganzhiName, sexagenaryIndex, STEMS, BRANCHES } from './calendar.js';
6
6
  import { solarToLunar } from './lunar.js';
7
- import { assertCivil } from './validate.js';
7
+ import { assertCivil, assertShifted } from './validate.js';
8
8
  import { ganyingOf, LIUYI_RU_MU } from './qimen-ganying.js';
9
9
 
10
10
  const TIAN_GAN = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'];
@@ -52,11 +52,15 @@ export function buildSnapshot({ year, month, day, hour, minute, lateZiHourMode =
52
52
  const baseJdn = partsToJd({ year, month, day, hour: 12, minute: 0 });
53
53
  const jdn = Math.floor(baseJdn + 0.5);
54
54
  const isLateZi = hour >= 23;
55
+ // 晚子时归次日若跨出 1600–2400,直接拒绝:不能拿 2401 的日柱排盘
56
+ if (isLateZi && lateZiHourMode === 'next-day') assertShifted(jdToParts(jd + 1), '奇门');
55
57
  const dayJdn = isLateZi && lateZiHourMode === 'next-day' ? jdn + 1 : jdn;
56
58
  const dayGz = ganzhiName((((dayJdn + 49) % 60) + 60) % 60).name;
57
59
 
58
- // 时干支(五鼠遁):23:00 之后时柱一律按次日的日干起子时,
59
- // 这样 same-day / next-day 两种口径只有日柱不同、时柱一致(与参照实现一致)。
60
+ // 时干支(五鼠遁):23:00 之后时柱一律按「次日」日干起子时。
61
+ // 因此 same-day / next-day 两种口径只差日柱、时柱保持一致;代价是会出现
62
+ // 「甲子日 + 丙子时」这种按当日日干推不出的组合(晚子时固定口径,README 与测试已锁)。
63
+ // 奇门以时柱定值符值使与局,两个口径的差异从日柱进入,不能把日柱当成无关紧要的细节。
60
64
  const hourBranchIdx = Math.floor(((hour + 1) % 24) / 2);
61
65
  const hourStemBasisJdn = isLateZi ? jdn + 1 : jdn;
62
66
  const hourBasisGz = ganzhiName((((hourStemBasisJdn + 49) % 60) + 60) % 60).name;
@@ -204,7 +208,8 @@ export function stemPalaceRelation(stem, palace) {
204
208
  // ── 六仪击刑 / 三奇入墓 ─────────────────────────────────────
205
209
  // 两者都是「天盘干落到某个宫就成立」的硬条件,与门迫一样只作宫位条件,不下吉凶。
206
210
  // 六仪击刑按地支相刑推:子刑卯、戌刑未、申刑寅、午自刑、辰自刑、寅刑巳;
207
- // 三奇入墓按五行墓库推:乙木墓未(未在坤二宫)、丙丁火墓戌(戌在乾六宫)。
211
+ // 三奇入墓按《玄机赋》阴干逆行取墓:乙阴木墓戌、丙阳火墓戌(同落乾六),丁阴火墓丑(艮八)。
212
+ // 注意别再混入「阳木墓未」——那套口径会把乙判到坤二,与本插件引文相反。
208
213
  const JI_XING = {
209
214
  戊: { palace: '3', zhi: '卯', basis: '甲子戊,子刑卯' },
210
215
  己: { palace: '2', zhi: '未', basis: '甲戌己,戌刑未' },
@@ -214,9 +219,9 @@ const JI_XING = {
214
219
  癸: { palace: '4', zhi: '巳', basis: '甲寅癸,寅刑巳' },
215
220
  };
216
221
  const RU_MU = {
217
- 乙: { palace: '2', zhi: '未', basis: '乙木墓于未,未在坤二宫' },
222
+ 乙: { palace: '6', zhi: '戌', basis: '乙阴木墓于戌(玄机赋),戌在乾六宫' },
218
223
  丙: { palace: '6', zhi: '戌', basis: '丙火墓于戌,戌在乾六宫' },
219
- 丁: { palace: '6', zhi: '戌', basis: '丁火墓于戌,戌在乾六宫' },
224
+ 丁: { palace: '8', zhi: '丑', basis: '丁阴火墓于丑(玄机赋),丑在艮八宫' },
220
225
  };
221
226
 
222
227
  /** 天盘六仪落刑宫 → [{palace, stem, zhi, basis}] */
@@ -357,7 +362,7 @@ function distributeBaShen(zhiFuGong, type) {
357
362
  return baShen;
358
363
  }
359
364
 
360
- function xunShouLiuYi(gz) {
365
+ export function xunShouLiuYi(gz) {
361
366
  const tg = TIAN_GAN.indexOf(gz[0]);
362
367
  const dz = DI_ZHI.indexOf(gz[1]);
363
368
  if (tg === -1 || dz === -1) return '戊';
@@ -401,13 +406,18 @@ function calcJuShu(snapshot, juMethod = 'chaibu') {
401
406
  termName = snapshot.fuTouTermName || termName;
402
407
  note = `置闰法:超神 ${lead} 天,但不在芒种/大雪窗口,按超神接气取${termName}`;
403
408
  } else {
404
- note = `置闰法:符头距${snapshot.nextTermName} ${lead} 天,未达置闰条件,按拆补处理`;
409
+ note = lead == null
410
+ ? '置闰法:缺少符头距下一节气天数,按拆补处理'
411
+ : `置闰法:符头距${snapshot.nextTermName} ${lead} 天,未达置闰条件,按拆补处理`;
405
412
  }
406
413
  }
407
414
  const nums = JU_NUMBERS_MAP[termName] || '111';
408
- const num = Number(nums.charAt(yuan));
415
+ // 局数与元名必须同源:置闰把元改到「下元」后,局数也得取 nums 的第三位,
416
+ // 否则会排出「阴遁九局(下元)」却用上元的局数
417
+ const effYuan = yuanOverride >= 0 ? yuanOverride : yuan;
418
+ const num = Number(nums.charAt(effYuan));
409
419
  const CN = { 1: '一', 2: '二', 3: '三', 4: '四', 5: '五', 6: '六', 7: '七', 8: '八', 9: '九' };
410
- const yuanName = ['上元', '中元', '下元'][yuanOverride >= 0 ? yuanOverride : yuan];
420
+ const yuanName = ['上元', '中元', '下元'][effYuan];
411
421
  return {
412
422
  jieQiName: termName,
413
423
  juMethod,
@@ -468,7 +478,8 @@ export function buildQimen(input) {
468
478
  });
469
479
  tianPan['5'] = diPan['5'] || ''; // 中五宫天盘取本宫地盘干
470
480
 
471
- const kongZhi = KONG_WANG[snapshot.dayXun === xunOf(hourGz) ? xunOf(hourGz) : xunOf(hourGz)] || [];
481
+ // 时家奇门按时辰所在旬取空亡(如甲子旬空戌亥),不看日旬
482
+ const kongZhi = KONG_WANG[xunOf(hourGz)] || [];
472
483
  const kongGong = kongZhi.map((z) => ZHI_TO_GONG[z]).filter(Boolean);
473
484
  const maMap = { 申: '寅', 子: '寅', 辰: '寅', 寅: '申', 午: '申', 戌: '申', 亥: '巳', 卯: '巳', 未: '巳', 巳: '亥', 酉: '亥', 丑: '亥' };
474
485
  const maZhi = maMap[hourGz[1]] || '';
@@ -566,7 +577,7 @@ export function buildQimen(input) {
566
577
  '定局:以日干支符头定上中下元(子午卯酉上元、寅申巳亥中元、辰戌丑未下元),按节气取局数',
567
578
  '阴阳遁:冬至至夏至前用阳遁,夏至至冬至前用阴遁',
568
579
  '中五宫寄坤二宫;天盘随值符旋转,八神随值符落宫顺(阳)/逆(阴)布',
569
- '本次未实现超神接气与置闰,采用拆补法',
580
+ '定局可选拆补法(默认)、超神接气法、置闰法:超神接气局随符头,置闰仅在芒种/大雪后符头超前 9 日以上时启用',
570
581
  ],
571
582
  };
572
583
  }
package/engine/rectify.js CHANGED
@@ -11,6 +11,8 @@ const KINDS = ['career', 'wealth', 'love', 'move', 'health', 'other'];
11
11
  const HOURS = [0, 1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 23];
12
12
  /** 事件条数上限:对账用不到几百条,超限是输入异常而不是需求 */
13
13
  const MAX_EVENTS = 200;
14
+ /** 事件年份跨度上限:人活着的事件区间本来就窄,超宽只会把流年表撑爆 */
15
+ const MAX_SPAN = 120;
14
16
 
15
17
  const TEN_GOD = (l) => l.stemTenGod || '';
16
18
  const has = (l, re) => re.test((l.relations || []).join(''));
@@ -81,7 +83,26 @@ export function rectifyBirth(opts) {
81
83
  e.code = 'E_INPUT';
82
84
  throw e;
83
85
  }
86
+ // 出生前的事件不参与对账:以前照样进窗口并计分,一个填错的年份就能把整体排序拉走,
87
+ // 而排序看起来仍是「算出来的」。报错而不是静默丢掉——静默会让调用方以为它在名单里。
88
+ const beforeBirth = clean.filter((x) => x.year < base.year);
89
+ if (beforeBirth.length) {
90
+ const e = new Error(`事件年份早于出生年 ${base.year}:${beforeBirth.map((x) => x.year).join('、')}——生时校正只对出生以后发生的事打分`);
91
+ e.code = 'E_INPUT';
92
+ throw e;
93
+ }
84
94
  const nowYear = clean.reduce((m, x) => (x.year < m ? x.year : m), clean[0].year);
95
+ // 流年表默认只有 10 条:事件比最早一条晚十年以上时永远查不到,被当成「没对上」,
96
+ // 对所有候选同样落空,只会白白拉低命中率。窗口要覆盖到最晚一条事件。
97
+ const lastYear = clean.reduce((m, x) => (x.year > m ? x.year : m), clean[0].year);
98
+ // 窗口按事件年份逐年排盘,跨度不设上限就等于「两个年份决定要排多少张盘」:
99
+ // [{year:1601},{year:2399}] 会让每个候选摊上 799 年流年,实测 1267ms(30 年跨度只有 9ms)。
100
+ if (lastYear - nowYear + 1 > MAX_SPAN) {
101
+ const e = new Error(`事件年份跨度太大:${nowYear}–${lastYear} 共 ${lastYear - nowYear + 1} 年,最多 ${MAX_SPAN} 年`
102
+ + '——只报真正相关的几十年,别用两个极端年份把窗口撑开');
103
+ e.code = 'E_INPUT';
104
+ throw e;
105
+ }
85
106
  // 候选时辰同样要限:一天只有 12 个时辰,给了 1000 个就是白排 1000 张盘
86
107
  const wanted = Array.isArray(opts.hours) && opts.hours.length
87
108
  ? [...new Set(opts.hours.filter((h) => Number.isInteger(h) && h >= 0 && h <= 23))]
@@ -106,6 +127,7 @@ export function rectifyBirth(opts) {
106
127
  longitude: typeof opts.longitude === 'number' ? opts.longitude : undefined,
107
128
  geo: opts.geo && typeof opts.geo === 'object' ? opts.geo : undefined,
108
129
  nowYear,
130
+ liunianTo: lastYear,
109
131
  });
110
132
  const matched = [];
111
133
  const missed = [];
@@ -126,23 +148,42 @@ export function rectifyBirth(opts) {
126
148
  missedYears: missed.map((x) => x.year),
127
149
  };
128
150
  });
129
- candidates.sort((a, b) => b.score - a.score || a.hour - b.hour);
151
+ // 真太阳时校正后,不同整点可能落进同一时辰(如东经 114.5° 一带 0:00 与 1:00 都在子时),
152
+ // 排出来的盘完全一样。重复列 14 条会让用户以为有 14 种可能,这里按盘面合并。
153
+ const merged = [];
154
+ const seen = new Map();
155
+ for (const c of candidates) {
156
+ const key = `${c.dayGanzhi}|${c.hourGanzhi}`;
157
+ const hit = seen.get(key);
158
+ if (hit) {
159
+ hit.hours.push(c.hour);
160
+ hit.label = `${hit.hours.map((h) => `${String(h).padStart(2, '0')}:00`).join(' / ')} 起`;
161
+ continue;
162
+ }
163
+ const item = { ...c, hours: [c.hour], label: `${String(c.hour).padStart(2, '0')}:00 起` };
164
+ seen.set(key, item);
165
+ merged.push(item);
166
+ }
167
+ merged.sort((a, b) => b.score - a.score || a.hour - b.hour);
130
168
 
131
- const days = [...new Set(candidates.map((c) => c.dayGanzhi))];
132
- const best = candidates[0];
133
- const second = candidates[1];
169
+ const days = [...new Set(merged.map((c) => c.dayGanzhi))];
170
+ const best = merged[0];
171
+ const second = merged[1];
134
172
  const tie = second && second.score === best.score;
173
+ const dropped = candidates.length - merged.length;
135
174
 
136
175
  return {
137
176
  system: 'bazi',
138
177
  base,
139
178
  events: clean,
140
179
  hours,
141
- candidates,
180
+ candidates: merged,
142
181
  best: best || null,
143
182
  tie,
144
183
  dayChanged: days.length > 1,
145
- note: `候选 ${candidates.length} 个:命中数最高 ${best ? best.hits : 0}/${clean.length}${tie ? `(有并列:${candidates.filter((c) => c.score === best.score).map((c) => c.hour).join('、')} 点)` : ''}。` +
184
+ note: `候选 ${merged.length} 组:命中数最高 ${best ? best.hits : 0}/${clean.length}${tie ? `(有并列:${merged.filter((c) => c.score === best.score).map((c) => c.hours.join('/')).join('、')} 点)` : ''}。` +
185
+ (dropped ? `输入 ${candidates.length} 个整点中有 ${dropped} 个与相邻整点落在同一时辰,已合并为同一组。` : '') +
186
+ `事件按 ${nowYear}–${lastYear} 年逐年对账(共 ${lastYear - nowYear + 1} 年)。` +
146
187
  (days.length > 1 ? `注意:跨 23:00 的候选会改变日柱(${days.join(' / ')}),这类差异比时柱更关键。` : '候选之间只差时柱(日柱未变)。') +
147
188
  ' 这是缩小范围的参考,不替代出生记录;命中数接近时不要硬选。',
148
189
  };
package/engine/star.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // 星座档案 + 当日娱乐向基调:纯本地确定性(同一天同一星座结果永远一致)。
2
2
  // 星座按太阳过宫的通行日期区间(公历);当日基调由日期哈希生成,不是随机数。
3
3
  import { daysInMonth } from './validate.js';
4
+ import { fail } from './fail.js';
4
5
 
5
6
  /** 12 星座(区间含起止日;摩羯跨年由两端分别匹配)。 */
6
7
  export const SIGNS = [
@@ -51,10 +52,10 @@ export function buildStar(input) {
51
52
  const { year, month, day } = input || {};
52
53
  if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)
53
54
  || year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
54
- throw new Error('星座需要真实存在的公历日期(year 1900–2100、month、day)——请向用户询问');
55
+ throw fail('E_INPUT', '星座需要真实存在的公历日期(year 1900–2100、month、day)——请向用户询问');
55
56
  }
56
57
  const sign = signOfDate(month, day);
57
- if (!sign) throw new Error(`无法识别星座:${year}-${month}-${day}`);
58
+ if (!sign) throw fail('E_INPUT', `无法识别星座:${year}-${month}-${day}`);
58
59
  const dims = ['事业', '感情', '财运', '健康'].map((k, i) => {
59
60
  const stars = (daySeed(year, month, day, i + 1) % 5) + 1;
60
61
  return { name: k, stars, text: TONE_TEXT[stars] };