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/tarot.js CHANGED
@@ -13,6 +13,8 @@
13
13
  // 不采用「逆位即纯凶」的流派;不同流派对逆位的取舍不同,此处取中性偏提醒。
14
14
  // 以上差异在 reading 与结果字段 systemNote 中均有标注,避免被当作唯一正解。
15
15
  import { CARD_LIB, CARD_COUNTS } from './tarot-cards.js';
16
+ import { fail } from './fail.js';
17
+ import { assertObject } from './validate.js';
16
18
 
17
19
  const MAJOR = [
18
20
  ['愚者', '0', '新开始 冒险 纯真 无限可能', '鲁莽 逃避 原地打转 不切实际'],
@@ -289,7 +291,15 @@ function stamp(at) {
289
291
  * 生成解读(确定性:同样的 question + cards 必得同样的段落)
290
292
  * 段落数固定 6-8:开场 / 逐张(1-3 张各一段;5 张按 2+3 分两段)/ 结构 / 关键牌 / 建议 / 收尾
291
293
  */
292
- export function tarotReading({ question = '', cards = [] } = {}) {
294
+ export function tarotReading(input = {}) {
295
+ assertObject(input, 'tarotReading 的参数', '{ question?, cards }:cards 用 drawTarot 抽出的牌面对象数组');
296
+ const question = input.question;
297
+ // cards 不是数组(字符串、对象)以前会被 filter 成空,回一句「没有抽到牌面」——
298
+ // 那是参数形状错了,不是真的没牌。
299
+ if (input.cards !== undefined && input.cards !== null && !Array.isArray(input.cards)) {
300
+ throw fail('E_INPUT', `tarotReading 的 cards 必须是牌面对象数组(drawTarot 的 cards),收到 ${typeof input.cards}`);
301
+ }
302
+ const cards = input.cards;
293
303
  const q = String(question || '').trim();
294
304
  const list = (Array.isArray(cards) ? cards : []).filter((c) => c && c.name);
295
305
  const n = list.length;
@@ -324,8 +334,9 @@ export function tarotReading({ question = '', cards = [] } = {}) {
324
334
  const ast = meta.astro ? `它对应的占星位置是${meta.astro},` : '';
325
335
  const role = !ast && meta.role ? `它在这里顶的是「${meta.role}」那个角色,` : '';
326
336
  const core = meta.core ? `它的核心是「${meta.core}」。` : '';
337
+ const posName = c.position || `第 ${i + 1} 张`;
327
338
  const tail = POS_TAIL[c.position] || '把它放回你的处境里对照,看看哪一句戳中了你。';
328
- return `${c.position}(${hint})是「${c.name}」${ori}:${kws}。${img}${ast}${role}${core}${oriNote}。${tail}`;
339
+ return `${posName}(${hint})是「${c.name}」${ori}:${kws}。${img}${ast}${role}${core}${oriNote}。${tail}`;
329
340
  });
330
341
  paras.push(lines.join(''));
331
342
  }
@@ -371,7 +382,8 @@ export function tarotReading({ question = '', cards = [] } = {}) {
371
382
  : revCard
372
383
  ? '全盘没有大阿卡纳,关节就落在第一张逆位上——那股还没顺过来的力量'
373
384
  : '全盘既没有大阿卡纳也没有逆位,走势平顺,就以第一张为参照';
374
- paras.push(`全盘的关节是「${key.name}」(${key.position}·${key.upright === false ? '逆位' : '正位'})${keyMeta.core ? `,${keyMeta.core}` : ''}。${keyReason};其余几张可以看作它在不同位置上的回声。`);
385
+ const keyPos = key.position || `第 ${list.indexOf(key) + 1} 张`;
386
+ paras.push(`全盘的关节是「${key.name}」(${keyPos}·${key.upright === false ? '逆位' : '正位'})${keyMeta.core ? `,${keyMeta.core}` : ''}。${keyReason};其余几张可以看作它在不同位置上的回声。`);
375
387
 
376
388
  // ⑤ 建议:据牌面给 2-3 条可执行的行动建议
377
389
  const tips = [];
@@ -381,7 +393,7 @@ export function tarotReading({ question = '', cards = [] } = {}) {
381
393
  if (missing.length) tips.push(`补一点${missing[0]}的力量:${ELEM_TIP[missing[0]]}`);
382
394
  else if (heavy) tips.push(`${dominant}已经偏旺,${ELEM_WARN[dominant]}`);
383
395
  const adviceCard = list.find((c) => c.position === '建议');
384
- if (adviceCard) tips.push(`把「${adviceCard.name}」${adviceCard.orientation}的关键词(${(adviceCard.keywords || []).join('、')})当作接下来一周的小练习`);
396
+ if (adviceCard) tips.push(`把「${adviceCard.name}」${adviceCard.upright === false ? '逆位' : '正位'}的关键词(${(adviceCard.keywords || []).join('、')})当作接下来一周的小练习`);
385
397
  const picked = tips.slice(0, 3);
386
398
  paras.push(`建议:其一,${picked[0]};其二,${picked[1]}${picked[2] ? `;其三,${picked[2]}` : ''}。`);
387
399
 
@@ -392,8 +404,14 @@ export function tarotReading({ question = '', cards = [] } = {}) {
392
404
  }
393
405
 
394
406
  /** 抽牌:count 张,按牌阵给出位置。random 可注入(配合 seed 复现同一次抽牌) */
395
- export function drawTarot({ count = 3, question = '', at, random = Math.random } = {}) {
396
- const n = [1, 3, 5].includes(count) ? count : 3;
407
+ export function drawTarot(input = {}) {
408
+ const { count: rawCount, question = '', at, random = Math.random } = input || {};
409
+ let count = rawCount;
410
+ if (count === undefined || count === null || count === '') count = 3;
411
+ const n = Number(count);
412
+ if (![1, 3, 5].includes(n)) {
413
+ throw fail('E_INPUT', `塔罗张数只能是 1 / 3 / 5,收到「${count}」`);
414
+ }
397
415
  const rand = typeof random === 'function' ? random : Math.random;
398
416
  const deck = tarotDeck();
399
417
  for (let i = deck.length - 1; i > 0; i--) {
package/engine/tiaohou.js CHANGED
@@ -42,8 +42,10 @@ const NEED = {
42
42
  寒燥: { element: '火', stem: '丙', avoid: '水', why: '天寒地干,先要一点阳和之气' },
43
43
  寒: { element: '火', stem: '丙', avoid: '水', why: '冬令气寒,非火不暖' },
44
44
  温热: { element: '水', stem: '壬', avoid: '火', why: '火气初旺,得水润泽才好用' },
45
+ // 热且湿:闷蒸之局。解法仍是泄热(壬水),不是「湿气重就加火」——那等于往蒸笼下添柴。
46
+ // 单列一档而不是并进「湿滞」,是因为湿滞属不热之湿(土湿要火蒸),两者取用相反。
47
+ 湿热: { element: '水', stem: '壬', avoid: '火', why: '湿热交蒸,得壬水引热下行,再添火只会更闷' },
45
48
  燥热: { element: '水', stem: '壬', avoid: '火', why: '火炎土燥,非水不润' },
46
- 热: { element: '水', stem: '壬', avoid: '火', why: '夏令炎威,先求一泓清水' },
47
49
  湿滞: { element: '火', stem: '丙', avoid: '水', why: '湿气郁结,要火来蒸腾' },
48
50
  燥: { element: '水', stem: '壬', avoid: '火', why: '气燥不润,得水则舒' },
49
51
  平和: { element: null, stem: null, avoid: null, why: '寒暖相济,气候不是这一盘的主要矛盾' },
@@ -51,7 +53,7 @@ const NEED = {
51
53
 
52
54
  const KIND_NAME = {
53
55
  寒湿: '寒湿', 寒燥: '寒燥', 寒: '寒',
54
- 温热: '温热', 燥热: '燥热', 热: '热',
56
+ 温热: '温热', 湿热: '湿热', 燥热: '燥热',
55
57
  湿滞: '湿滞', 燥: '燥', 平和: '平和',
56
58
  };
57
59
 
@@ -92,12 +94,12 @@ const DAY_FEEL = {
92
94
  金: '金在燥热里容易发烫变形,要水来淬',
93
95
  水: '水在燥热里最珍贵,一滴都不能浪费',
94
96
  },
95
- 热: {
96
- 木: '木在暑天要水,不然只长叶不结果',
97
- 火: '火已经当令,收着用比放出去更划算',
98
- 土: '土热则燥,得水则安',
99
- 金: '金在暑天要水来洗,才见光泽',
100
- 水: '水在暑天是命脉,要保住源头',
97
+ 湿热: {
98
+ 木: '木沤在湿热里容易烂根生虫,先要疏泄,不能再烘',
99
+ 火: '火在闷热里只会更闷,得引一点水气把它透开',
100
+ 土: '土湿热成泥沼,得疏得泄,再烘只会更黏',
101
+ 金: '金在湿热里容易生锈,要泄热、要见燥,才显出锋',
102
+ 水: '水不算少却带着热,得把热泄出去,水才用得干净',
101
103
  },
102
104
  湿滞: {
103
105
  木: '木泡在水里会烂根,先要疏土透气',
@@ -157,8 +159,11 @@ export function judgeClimate({ monthBranch, dayStem, wuxing = {}, branches = []
157
159
  else if (cold && drySide) kind = '寒燥';
158
160
  else if (cold) kind = '寒';
159
161
  else if (hot && drySide) kind = '燥热';
162
+ // 先判「热且湿」再判「单纯热」:原来 hot 先短路,hot && damp>=0.8 永远走不到。
163
+ // 接通它要单开一档「湿热」而不是套用「湿滞」:湿滞是不热之湿(要丙火蒸),
164
+ // 热而湿则是闷蒸(要壬水泄热)——复用湿滞会把偏热盘的喜用从水翻成火。
165
+ else if (hot && wetSide) kind = '湿热';
160
166
  else if (hot) kind = '温热';
161
- else if (hot && damp >= 0.8) kind = '湿滞';
162
167
  else if (drySide) kind = '燥';
163
168
  else if (wetSide) kind = '湿滞';
164
169
 
@@ -200,7 +205,9 @@ function buildNarration({ kind, monthBranch, dayElement, need, fire, water }) {
200
205
  if (!need.element) {
201
206
  return `${head}。${feel}。调候不是这一盘的主要矛盾,取用仍以扶抑为主,寒暖只作微调。`;
202
207
  }
203
- return `${head}。${feel}。所以这一局要的不是更多的${kind.includes('寒') ? '水' : '火'},而是${need.stem}${need.element}——${need.why}。`;
208
+ // 「不该再多的是什么」按 NEED 的忌神填,不按档名猜:猜「寒→水、其它→火」时,
209
+ // 湿滞(忌水、取丙火)会念成「要的不是更多的火,而是丙火」。
210
+ return `${head}。${feel}。所以这一局要的不是更多的${need.avoid},而是${need.stem}${need.element}——${need.why}。`;
204
211
  }
205
212
 
206
213
  /** 调候与扶抑的仲裁说明:冲突时把话说明白,不静默取一个 */
package/engine/trend.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // 只用于看形态与相对起伏;分高不等于真实吉凶,旺衰落在换档边界时连排序都不给。
6
6
  import { buildBazi, HIDDEN_STEMS, tenGod } from './bazi.js';
7
7
  import { ganzhiName, sexagenaryIndex, STEMS, BRANCHES } from './calendar.js';
8
+ import { fail } from './fail.js';
8
9
 
9
10
  // ── 十神计分表 ────────────────────────────────────────────────
10
11
  // 三张表各自求和接近 0,保证分数有正负双向起伏;符号随日主旺衰方向翻转:
@@ -86,9 +87,17 @@ function buildSegments(list, direction, birthYear, toAge) {
86
87
  export function buildTrend(input) {
87
88
  const gender = input.gender;
88
89
  if (gender !== 'male' && gender !== 'female') {
89
- throw new Error('人生 K 线需要性别:大运顺逆由性别决定,不可缺省');
90
+ throw fail('E_INPUT', '人生 K 线需要性别:大运顺逆由性别决定,不可缺省');
91
+ }
92
+ // 以前 toAge 只认整数:50.5 会被静默换成默认 100 岁,点数翻倍还看不出来
93
+ let toAge = 100;
94
+ if (input.toAge !== undefined && input.toAge !== null && input.toAge !== '') {
95
+ const n = Number(input.toAge);
96
+ if (!Number.isInteger(n) || n < 10 || n > 100) {
97
+ throw fail('E_INPUT', `toAge 要是 10–100 的整数(岁),收到「${input.toAge}」`);
98
+ }
99
+ toAge = n;
90
100
  }
91
- const toAge = clamp(Number.isInteger(input.toAge) ? input.toAge : 100, 10, 100);
92
101
  const r = buildBazi({
93
102
  year: input.year,
94
103
  month: input.month,
@@ -54,3 +54,33 @@ export function assertCivil(input, who = '') {
54
54
  if (minute < 0 || minute > 59) throw fail('E_INPUT', `${p}minute 超出范围 0–59(收到 ${minute})`);
55
55
  return input;
56
56
  }
57
+
58
+ /**
59
+ * 次级入口(build* 之外的导出函数)的形状守卫。
60
+ * 这些函数以前直接读 input.surname / hex.lower / bits.slice,传错形状时在深处抛裸 TypeError,
61
+ * 调用方只看见 "Cannot read properties of null",看不出是自己传错了什么。
62
+ * @param {*} value 待校验入参
63
+ * @param {string} who 函数名或参数名,用于定位
64
+ * @param {string} shape 该传什么(写清字段,别让调用方去猜)
65
+ */
66
+ export function assertObject(value, who, shape) {
67
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
68
+ const got = value === null ? 'null' : value === undefined ? 'undefined' : Array.isArray(value) ? '数组' : typeof value;
69
+ throw fail('E_INPUT', `${who} 必须是对象(收到 ${got});${shape}`);
70
+ }
71
+ return value;
72
+ }
73
+
74
+ /**
75
+ * 晚子时换日(23:00–23:59 归次日)之后再量一次年份闸门。
76
+ * 2400-12-31T23:30 能过入参校验,却被整体搬到 2401 出盘;而直接传 2401-01-01T00:30 会被拒。
77
+ * 同一张盘不该有「换日就通、直填就拒」两条口径——边界附近的输入必须同进同出。
78
+ */
79
+ export function assertShifted(date, who = '') {
80
+ const p = who ? `${who}:` : '';
81
+ if (date.year < MIN_YEAR || date.year > MAX_YEAR) {
82
+ throw fail('E_RANGE', `${p}晚子时按次日归后落到 ${date.year}-${String(date.month).padStart(2, '0')}-${String(date.day).padStart(2, '0')},`
83
+ + `超出可计算范围 ${MIN_YEAR}–${MAX_YEAR};请把时刻挪到 23 点前,或换一天起课`);
84
+ }
85
+ return date;
86
+ }
@@ -2,7 +2,10 @@
2
2
  // 口径:通行掌诀;断语为白话提炼(非原文照录)。轻量快占,属民俗参考。
3
3
  import { solarToLunar } from './lunar.js';
4
4
  import { hourToBranch } from './almanac.js';
5
- import { BRANCHES } from './calendar.js';
5
+ import { BRANCHES, fromJDN, toJDN } from './calendar.js';
6
+ import { parseYmd } from './datetime.js';
7
+ import { assertShifted } from './validate.js';
8
+ import { fail } from './fail.js';
6
9
 
7
10
  /** 六宫:吉凶 + 白话断语。 */
8
11
  export const PALACES = {
@@ -24,6 +27,9 @@ const pick = (n) => PALACE_ORDER[(((n - 1) % 6) + 6) % 6];
24
27
  * time 模式下 date 与 hour 必须成对(缺时辰则同一天任何时刻同宫,等于静默代填)。
25
28
  */
26
29
  export function buildXiaoliuren(input) {
30
+ if (!input || typeof input !== 'object') {
31
+ throw fail('E_INPUT', '小六壬起课参数不能为空:时间起课要 date / hour,数字起课要 num1 / num2 / num3');
32
+ }
27
33
  const mode = input.mode === 'number' ? 'number' : 'time';
28
34
  let m;
29
35
  let d;
@@ -31,20 +37,35 @@ export function buildXiaoliuren(input) {
31
37
  let originText;
32
38
  if (mode === 'time') {
33
39
  if (typeof input.hour !== 'number' || !Number.isInteger(input.hour) || input.hour < 0 || input.hour > 23) {
34
- throw new Error('时间起课必须同时给 date 与 hour(0–23):掐指按时辰推进,缺时辰不可代填');
40
+ throw fail('E_INPUT', '时间起课必须同时给 date 与 hour(0–23):掐指按时辰推进,缺时辰不可代填');
41
+ }
42
+ const mt = parseYmd(input.date, 'date');
43
+ if (!mt) {
44
+ throw fail('E_INPUT', input.date
45
+ ? `date 不是真实存在的公历日期:「${input.date}」,请用 2026-12-01 这种格式`
46
+ : '时间起课需要 date(公历 YYYY-MM-DD)——请向用户询问日期');
47
+ }
48
+ // 晚子时换日与八字/六壬/奇门同口径:23:00–23:59 归次日,否则同一子时前后会落到两个日子
49
+ let y = mt.year;
50
+ let mo = mt.month;
51
+ let dy = mt.day;
52
+ const lateZi = input.lateZi !== false;
53
+ if (lateZi && input.hour >= 23) {
54
+ const next = assertShifted(fromJDN(toJDN(y, mo, dy) + 1), '小六壬时间起课');
55
+ y = next.year;
56
+ mo = next.month;
57
+ dy = next.day;
35
58
  }
36
- const mt = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(String(input.date || '').trim());
37
- if (!mt) throw new Error('时间起课需要 date(公历 YYYY-MM-DD)——请向用户询问日期');
38
- const lunar = solarToLunar(Number(mt[1]), Number(mt[2]), Number(mt[3]));
59
+ const lunar = solarToLunar(y, mo, dy);
39
60
  const branch = hourToBranch(input.hour);
40
61
  m = lunar.month;
41
62
  d = lunar.day;
42
63
  h = BRANCHES.indexOf(branch) + 1;
43
- originText = `农历${lunar.month}月${lunar.day}日 ${branch}时:月${m} → 日${d} → 时${h} 掐指落宫`;
64
+ originText = `农历${lunar.month}月${lunar.day}日 ${branch}时:月${m} → 日${d} → 时${h} 掐指落宫${lateZi && input.hour >= 23 ? '(晚子时按次日)' : ''}`;
44
65
  } else {
45
66
  const nums = [input.num1, input.num2, input.num3].map(Number);
46
67
  if (nums.some((x) => !Number.isInteger(x) || x < 1 || x > 99)) {
47
- throw new Error('报数起课需要三个 1–99 的正整数(随口报即可)——缺数请向用户索要,不要代填');
68
+ throw fail('E_INPUT', '报数起课需要三个 1–99 的正整数(随口报即可)——缺数请向用户索要,不要代填');
48
69
  }
49
70
  [m, d, h] = nums;
50
71
  originText = `报数 ${m}、${d}、${h}:依次按月/日/时位掐指落宫`;
package/engine/yingqi.js CHANGED
@@ -29,13 +29,6 @@ const BRANCHES_OF = (() => {
29
29
  return out;
30
30
  })();
31
31
 
32
- export const YINGQI_RULE_IDS = [
33
- 'jing-zhi-chong', 'dong-he-zhi', 'taiwang-mu-chong', 'shuaijue-sheng-wang',
34
- 'rumu-chong-kai', 'liuhe-xiang-ji', 'yuepo-xi-tian', 'xunkong-tian-chong',
35
- 'jixiang-keshen-shouke', 'xiong-keshen-fengsheng', 'huajin-zhi-he',
36
- 'huatui-ji-zhi-chong', 'bianyao-dongyao', 'shikong-yuanshen-zhi', 'shishuai-yuanshen-chong',
37
- ];
38
-
39
32
  /**
40
33
  * 规则的排列顺序 = 原文《各門類應期總注章第又二十六》19 条的罗列次序。
41
34
  *
@@ -69,6 +62,9 @@ export const RULE_ORDER = [
69
62
  'shishuai-yuanshen-chong',
70
63
  ];
71
64
 
65
+ /** 规则编号表:与 RULE_ORDER 同一批规则,保留此导出名给老调用方 */
66
+ export const YINGQI_RULE_IDS = RULE_ORDER;
67
+
72
68
  // 条文里给出、但落不到「某支」上的条目:列出来,避免被误以为已经做了
73
69
  export const NOT_IMPLEMENTED = [
74
70
  '問有應於獨發獨靜:詳獨發章(要判「獨發/獨靜」整卦只有一爻動,属整卦层,未并入单爻规则)',
@@ -108,7 +104,6 @@ export function yingqiState(line, bianLine, ctx = {}) {
108
104
  if (day && heOf(branch) === day) heWith.push({ with: '日辰', branch: day });
109
105
  if (moving && bianBranch && heOf(branch) === bianBranch) heWith.push({ with: '变爻', branch: bianBranch });
110
106
  // 太旺:月建同气(旺/相)且日辰生扶或比和
111
- const monthTone = month ? (BRANCHES_OF[zhiElementOf(month)] || []) : [];
112
107
  const monthSame = !!(month && zhiElementOf(month) === element);
113
108
  const monthSheng = !!(month && zhiElementOf(month) === SHENG_ME[element]);
114
109
  const daySame = !!(day && zhiElementOf(day) === element);
@@ -121,7 +116,7 @@ export function yingqiState(line, bianLine, ctx = {}) {
121
116
  return {
122
117
  branch, element, moving, bianBranch, jinTui: jt, kongHit, monthPo, ruMu,
123
118
  heWith, taiWang, shuaiJue, monthSame, monthSheng, daySame, daySheng, monthKe,
124
- muBranch, monthSameBranches: monthTone,
119
+ muBranch,
125
120
  };
126
121
  }
127
122
 
package/engine/zeri.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // 择日:逐日按《协纪辨方书》卷十一「用事」命中宜忌,再用卷十「宜忌」六等与德神制化定吉凶方向,
2
2
  // 叠冲属相排除后排序。口径透明:这是排序引导,不是吉凶断语。
3
3
  import { fromJDN, toJDN } from './calendar.js';
4
+ import { parseYmd } from './datetime.js';
5
+ import { MAX_YEAR } from './validate.js';
4
6
  import { buildAlmanac, SHENGXIAO } from './almanac.js';
5
7
  import {
6
8
  computeDayShensha, taskShenshaHits, xingHitText, gradeDay,
@@ -105,23 +107,6 @@ const verdictOf = (grade, ctx) => {
105
107
  return { verdict: '回避', text: `卷十作 ${basis};${tally},${why}传统作诸事皆忌。`, excluded: false };
106
108
  };
107
109
 
108
- /**
109
- * 择日主函数。
110
- * @param {{task:string, startDate:string, days?:number, shengXiao?:string}} input
111
- * startDate 必填(公历 YYYY-MM-DD),避免引擎在无时钟环境下静默取「今天」。
112
- */
113
- /** 严格解析公历日期:2 月 30 日这类不存在的日子直接判非法,不滚到下个月 */
114
- function parseYmd(raw) {
115
- const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(String(raw || '').trim());
116
- if (!m) return null;
117
- const year = Number(m[1]);
118
- const month = Number(m[2]);
119
- const day = Number(m[3]);
120
- if (month < 1 || month > 12 || day < 1) return null;
121
- const back = fromJDN(toJDN(year, month, day));
122
- return back.year === year && back.month === month && back.day === day ? { year, month, day } : null;
123
- }
124
-
125
110
  const ymd = (p) => `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
126
111
 
127
112
  /**
@@ -132,6 +117,9 @@ const ymd = (p) => `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day
132
117
  * candidates 给了就只比这几个候选日(2–12 个,去重、保持输入顺序),startDate 与 days 不参与。
133
118
  */
134
119
  export function buildZeri(input) {
120
+ if (!input || typeof input !== 'object') {
121
+ throw bad('择日参数不能为空:要给 task 与 startDate(或 candidates)');
122
+ }
135
123
  const task = input.task;
136
124
  if (!TASKS[task]) throw bad(`不支持的事项「${task}」。支持:${Object.keys(TASKS).join('、')}`);
137
125
  const shengXiaoRaw = typeof input.shengXiao === 'string' ? input.shengXiao.trim() : '';
@@ -149,7 +137,7 @@ export function buildZeri(input) {
149
137
  const seen = new Set();
150
138
  candidates = [];
151
139
  for (const raw of input.candidates) {
152
- const p = parseYmd(raw);
140
+ const p = parseYmd(raw, '候选日');
153
141
  if (!p) throw bad(`候选日不是真实存在的公历日期:「${raw}」,请用 2026-12-01 这种格式`);
154
142
  const key = ymd(p);
155
143
  if (seen.has(key)) continue;
@@ -162,9 +150,12 @@ export function buildZeri(input) {
162
150
  let start = null;
163
151
  let days = 0;
164
152
  if (!candidates) {
165
- const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(String(input.startDate || '').trim());
166
- if (!m) throw bad('择日需要 startDate(公历 YYYY-MM-DD)——请向用户询问起算日,不要代填');
167
- start = { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) };
153
+ start = parseYmd(input.startDate, 'startDate');
154
+ if (!start) {
155
+ throw bad(input.startDate
156
+ ? `startDate 不是真实存在的公历日期:「${input.startDate}」,请改用 2026-12-01 这种格式`
157
+ : '择日需要 startDate(公历 YYYY-MM-DD)——请向用户询问起算日,不要代填');
158
+ }
168
159
  // days 以前只认 Number.isInteger,于是 "45"(模型很爱给字符串)与 45.5 都静默变成 30 天:
169
160
  // 用户要的区间被悄悄改短,而结果里只看得到实际扫了几天,看不出被改过。
170
161
  // 现在:能规范成整数的(含数字串)就规范,范围外或非数字一律报错,不猜。
@@ -177,6 +168,13 @@ export function buildZeri(input) {
177
168
  if (n < 1 || n > 60) throw bad(`择日的 days 要在 1–60 之间(${n} 超出范围);更长请用分段多次查询`);
178
169
  days = n;
179
170
  }
171
+ // 区间会一路扫到 start+days-1,越界得在这里说清楚:
172
+ // 让 buildAlmanac 去抛「黄历 的 year 超出支持范围」,用户填的是 startDate,跟黄历没关系。
173
+ const end = fromJDN(toJDN(start.year, start.month, start.day) + days - 1);
174
+ if (end.year > MAX_YEAR) {
175
+ throw fail('E_RANGE', `择日区间超出可计算范围:${ymd(start)} 起 ${days} 天会走到 ${ymd(end)}(上限 ${MAX_YEAR}-12-31);`
176
+ + '请把 startDate 提前或减少 days');
177
+ }
180
178
  }
181
179
 
182
180
  const spec = TASKS[task];
@@ -17,7 +17,9 @@ const MUT_SCORE = { 禄: 10, 权: 5, 科: 8, 忌: -10 };
17
17
  const STRONG_LANDS = ['命宫', '夫妻', '福德', '财帛', '官禄'];
18
18
 
19
19
  const palaceByBranch = (chart, branch) => (chart.palaces || []).find((p) => p.branch === branch) || null;
20
- const mainStars = (p) => (p && p.stars ? p.stars.filter((s) => s.type === 'main' || s.type === '吉星' || s.type === '煞星').map((s) => s.name) : []);
20
+ // buildZiwei 的星型标签是中文字面量:主星 / 辅星 / 吉星 / 煞星 / 杂曜。
21
+ // 这里原先写 'main',永远命中不了,合盘双方便只剩吉星与煞星。
22
+ const mainStars = (p) => (p && p.stars ? p.stars.filter((s) => s.type === '主星' || s.type === '吉星' || s.type === '煞星').map((s) => s.name) : []);
21
23
 
22
24
  /** 一方的某宫(按地支)在对方盘中落到哪个宫位 */
23
25
  function landOn(host, branch) {
@@ -124,13 +124,14 @@ export const HUA_READ = {
124
124
  忌: { key: '执', gist: '容易在意、反复,是心里放不下的那一块' },
125
125
  };
126
126
 
127
- // ── 庙旺得利平不陷:同一条结论的七种语气 ──────────────────────────
127
+ // ── 庙旺得利平闲不陷:同一条结论的语气档(含五档亮度表里的「闲」)─────────
128
128
  export const BRIGHT_READ = {
129
129
  庙: { word: '得地', tone: 'good', plain: '长处放得开,几乎是本色出演' },
130
130
  旺: { word: '有力', tone: 'good', plain: '特点容易显出来,做事有底气' },
131
131
  得: { word: '尚可', tone: 'neutral', plain: '能用,需要一点外力配合' },
132
132
  利: { word: '中平', tone: 'neutral', plain: '长处与短板都在,看怎么用' },
133
133
  平: { word: '偏弱', tone: 'neutral', plain: '不太争得到位置,宜借势' },
134
+ 闲: { word: '闲地', tone: 'neutral', plain: '可用的力不多,宜顺势而不宜硬求' },
134
135
  不: { word: '不得地', tone: 'bad', plain: '长处不容易使上,得多绕一步' },
135
136
  陷: { word: '失地', tone: 'bad', plain: '容易往负面走,更需要自觉' },
136
137
  };
package/engine/ziwei.js CHANGED
@@ -3,10 +3,11 @@ import { STEMS, BRANCHES, ganzhiName, sexagenaryIndex, jdToParts, partsToJd, for
3
3
  import { solarToLunar } from './lunar.js';
4
4
  import { applyTrueSolar } from './bazi.js';
5
5
  import { applyDst } from './dst.js';
6
- import { assertCivil } from './validate.js';
6
+ import { assertCivil, assertShifted } from './validate.js';
7
7
  import { nayinOf } from './nayin.js';
8
8
  import { resolveAddress } from './geo.js';
9
9
  import { placeZuoyao, zuoyaoVerseBlock, ZUOYAO_SKIPPED } from './ziwei-zuoyao.js';
10
+ import { SHA_STAR } from './ziwei-words.js';
10
11
 
11
12
  const fix = (i) => ((i % 12) + 12) % 12;
12
13
 
@@ -102,7 +103,7 @@ export const ZIWEI_SCHOOL_OPTIONS = Object.freeze({
102
103
  tianmaSchool: ['year-branch', 'month-branch'],
103
104
  tiankongSchool: ['off', 'normal', 'shun-jia-shi'],
104
105
  jiekongSchool: ['off', 'double', 'single', 'zhanyan'],
105
- kuiyueSchool: ['qs1', 'qs2', 'zhong-yiming', 'network'],
106
+ kuiyueSchool: ['qs1', 'qs2', 'zhong-yiming'],
106
107
  tianshiTianshang: ['normal', 'zhongzhou'],
107
108
  changshengSchool: ['yin-yang-shun-ni', 'shui-tu', 'huo-tu'],
108
109
  lateZishi: ['current-day', 'next-day', 'all-current', 'day-cur-time-next'],
@@ -224,7 +225,7 @@ function mutagenOf(stem, schools) {
224
225
  return MUTAGEN[stem] ?? {};
225
226
  }
226
227
 
227
- // 安魁钺四派:差异集中在庚辛壬癸年
228
+ // 安魁钺三派:差异集中在庚辛壬癸年(「网络派」与 qs1 同表,不单列)
228
229
  export const KUI_YUE_SCHOOLS = {
229
230
  qs2: {
230
231
  甲: [1, 7], 戊: [1, 7], 庚: [6, 2], 乙: [0, 8], 己: [0, 8],
@@ -234,10 +235,6 @@ export const KUI_YUE_SCHOOLS = {
234
235
  甲: [1, 7], 戊: [1, 7], 庚: [2, 6], 乙: [0, 8], 己: [0, 8],
235
236
  丙: [11, 9], 丁: [11, 9], 辛: [2, 6], 壬: [5, 3], 癸: [5, 3],
236
237
  },
237
- network: {
238
- 甲: [1, 7], 戊: [1, 7], 庚: [1, 7], 乙: [0, 8], 己: [0, 8],
239
- 丙: [11, 9], 丁: [11, 9], 辛: [6, 2], 壬: [3, 5], 癸: [3, 5],
240
- },
241
238
  };
242
239
 
243
240
  // 截空(按生年干):single 单星、double 正副双星、zhanyan 占验派
@@ -415,7 +412,12 @@ export function buildZiwei(input) {
415
412
  // 紫微按「时辰」起盘:真太阳时若跨时辰/跨日,命盘随之改变
416
413
  // 晚子时 next-day:23:00 后起盘日顺延一日,只改起盘日,不改真太阳时显示
417
414
  let eff = jdToParts(solar.jd);
418
- if (schools.lateZishi === 'next-day' && eff.hour >= 23) eff = jdToParts(solar.jd + 1);
415
+ if (schools.lateZishi === 'next-day' && eff.hour >= 23) {
416
+ const shifted = jdToParts(solar.jd + 1);
417
+ // 晚子时 next-day 若把起盘日推到 2401,同样直接拒绝(直填 2401 本来就被 E_RANGE 拦)
418
+ assertShifted(shifted, '紫微');
419
+ eff = shifted;
420
+ }
419
421
  const year = eff.year;
420
422
  const month = eff.month;
421
423
  const day = eff.day;
@@ -642,7 +644,8 @@ export function buildZiwei(input) {
642
644
  branch: BRANCHES[branch],
643
645
  palace: palaceNames[branch],
644
646
  mutagen: self.mutagen ?? null,
645
- sha: stars.filter((s) => s.type === '煞星').map((s) => s.name),
647
+ // 只有解读词库认的煞星才算「会煞」;full 档新加的天月/阴煞/蜚廉不参与清杂判定
648
+ sha: stars.filter((s) => s.type === '煞星' && SHA_STAR[s.name]).map((s) => s.name),
646
649
  };
647
650
  };
648
651
  const wc = pick(wenchang, '文昌');
@@ -1308,7 +1311,9 @@ export function enrichLiunian(chart) {
1308
1311
  export function asOfParts(v) {
1309
1312
  if (!v) return null;
1310
1313
  if (typeof v === 'object') return v;
1311
- const m = String(v).match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{1,2}))?$/);
1314
+ // 首尾空格照 datetime 的规矩 trim 掉:模型输出的 asOf 常带空格,
1315
+ // 以前 ' 2026-10-22 ' 解析不出来,报 E_ASOF 说「请用 2026-10-22T16:00 这种格式」
1316
+ const m = String(v).trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{1,2}))?$/);
1312
1317
  if (!m) return null;
1313
1318
  return {
1314
1319
  year: Number(m[1]), month: Number(m[2]), day: Number(m[3]),