dsh-metaphysics 0.13.6 → 0.14.3

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
@@ -1,30 +1,77 @@
1
1
  // 统一入口:解析参数、分派到各体系、生成可读摘要
2
2
  import { buildBazi } from './bazi.js';
3
3
  import { buildLiuyao, tossLines } from './liuyao.js';
4
- import { buildZiwei, BRIGHTNESS, flowOf } from './ziwei.js';
4
+ import { buildZiwei, BRIGHTNESS, flowOf, asOfParts } from './ziwei.js';
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';
9
+ import { buildZiweiHepan } from './ziwei-hepan.js';
10
+ import { buildQimen } from './qimen.js';
8
11
  import { geocodeAddress, resolveAddress } from './geo.js';
9
12
  import { toJDN, ganzhiName, dayGanzhiIndex } from './calendar.js';
13
+ import { randomOf, isSeed } from './random.js';
10
14
  import { interpret, DIRECTIONS, DIRECTION_DEFAULT } from './interpret.js';
11
15
 
12
16
  export { interpret, DIRECTIONS, DIRECTION_DEFAULT, BRIGHTNESS, flowOf };
13
17
 
14
18
  const pad = (n) => String(n).padStart(2, '0');
15
19
 
20
+ /** 带错误码的参数错误:工具层据此给出可执行提示,而不是把内部异常直出给模型 */
21
+ const fail = (code, message) => {
22
+ const e = new Error(message);
23
+ e.code = code;
24
+ return e;
25
+ };
26
+
27
+ const GENDERS = ['male', 'female'];
28
+
29
+ function daysInMonth(year, month) {
30
+ if (month === 2) {
31
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
32
+ return leap ? 29 : 28;
33
+ }
34
+ return [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
35
+ }
36
+
37
+ /**
38
+ * 性别是必填项:它决定大运/大限的顺逆,缺省按某一方排等于给出一张错盘。
39
+ * 之前用 `=== 'female' ? 'female' : 'male'` 兜底,静默把未填性别的人按男命排。
40
+ */
41
+ function requireGender(value, who = '') {
42
+ if (GENDERS.includes(value)) return value;
43
+ const got = value === undefined || value === null || value === '' ? '(未提供)' : `「${value}」`;
44
+ throw fail('E_INPUT', `${who ? who + '的' : ''}gender 必须是 male 或 female${got};性别决定大运/大限顺逆,不能靠默认值`);
45
+ }
46
+
16
47
  /** 解析 'YYYY-MM-DDTHH:mm' / 'YYYY-MM-DD HH:mm' / 'YYYY-MM-DD' */
17
48
  export function parseDateTime(text) {
18
- if (typeof text !== 'string' || !text.trim()) throw new Error('缺少 datetime(公历日期时间)');
19
- const m = text.trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T\s](\d{1,2}):(\d{1,2}))?$/);
20
- if (!m) throw new Error(`无法解析 datetime:「${text}」,请用 1990-05-12T14:30 这种格式`);
49
+ if (typeof text !== 'string' || !text.trim()) {
50
+ throw fail('E_INPUT', '缺少 datetime(公历日期时间),例如 1990-05-12T14:30');
51
+ }
52
+ const m = text.trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T\s](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/);
53
+ if (!m) throw fail('E_INPUT', `无法解析 datetime:「${text}」,请用 1990-05-12T14:30 这种格式`);
21
54
  const out = {
22
55
  year: Number(m[1]), month: Number(m[2]), day: Number(m[3]),
23
56
  hour: Number(m[4] ?? 0), minute: Number(m[5] ?? 0),
24
57
  };
25
- if (out.month < 1 || out.month > 12) throw new Error('月份超出范围');
26
- if (out.day < 1 || out.day > 31) throw new Error('日期超出范围');
27
- if (out.hour > 23 || out.minute > 59) throw new Error('时间超出范围');
58
+ if (out.month < 1 || out.month > 12) throw fail('E_INPUT', `月份超出范围:${out.month}`);
59
+ // 只校验 1..31 会让 1990-02-30、2026-04-31 这类日期静默滚到下个月,整盘错位且无人察觉
60
+ const maxDay = daysInMonth(out.year, out.month);
61
+ if (out.day < 1 || out.day > maxDay) {
62
+ throw fail('E_INPUT', `日期不存在:${out.year}-${out.month}-${out.day}(${out.year} 年 ${out.month} 月只有 ${maxDay} 天)`);
63
+ }
64
+ if (out.hour > 23 || out.minute > 59) throw fail('E_INPUT', `时间超出范围:${out.hour}:${out.minute}`);
65
+ // ΔT(地球自转改正)表只覆盖 1900–2100,超出后按端点值外推,精度不可保证
66
+ if (out.year < 1600 || out.year > 2400) {
67
+ throw fail('E_RANGE', `年份 ${out.year} 超出可计算范围(1600–2400)`);
68
+ }
69
+ if (out.year < 1900 || out.year > 2100) {
70
+ out.precision = {
71
+ level: 'approx',
72
+ note: `ΔT 模型仅覆盖 1900–2100,${out.year} 年按端点值外推,节气与朔望时刻可能有分钟级偏差,月柱在交节前后需复核`,
73
+ };
74
+ }
28
75
  return out;
29
76
  }
30
77
 
@@ -35,10 +82,14 @@ function baziText(r) {
35
82
  + ` 地势 ${p.dishi} 自坐 ${p.zizuo} 空亡 ${p.kongwang.join('')}`
36
83
  + (p.shensha && p.shensha.length ? ` 神煞 ${p.shensha.join('、')}` : '');
37
84
  const wuxing = Object.entries(r.wuxing).map(([k, v]) => `${k}${v}`).join(' ');
85
+ const palaces = r.palaces
86
+ ? `【胎元】${r.palaces.taiyuan.ganzhi} 命宫 ${r.palaces.minggong.ganzhi} 身宫 ${r.palaces.shengong.ganzhi}`
87
+ : '';
38
88
  const dayun = r.dayun.list.slice(0, 8)
39
89
  .map((d) => `${d.startAge}岁 ${d.ganzhi}(${d.stemTenGod})${d.luck ? `·${d.luck.tier}` : ''}${d.relations && d.relations.length ? `[${d.relations.join('')}]` : ''}`).join(' ');
40
- const near = r.jieqi.nearBoundaryMinutes < 15
41
- ? `\n⚠ 出生时刻距「节」仅 ${r.jieqi.nearBoundaryMinutes} 分钟,节气时刻本身有分钟级误差,月柱建议人工复核。`
90
+ // 阈值 20 分钟:与 NAOJ 历要项对拍实测,本引擎节气最多偏早 13.5 分钟(Meeus 低精度上限约 14 分钟)
91
+ const near = r.jieqi.nearBoundaryMinutes < 20
92
+ ? `\n⚠ 出生时刻距「节」仅 ${r.jieqi.nearBoundaryMinutes} 分钟,而节气时刻本身有最多约 14 分钟的偏差,月柱与年柱建议人工复核。`
42
93
  : '';
43
94
  const SHORT = { year: '年', month: '月', day: '日', hour: '时' };
44
95
  const shenshaLine = r.shensha && r.shensha.groups && r.shensha.groups.length
@@ -57,12 +108,13 @@ function baziText(r) {
57
108
  `日主:${r.dayMaster.stem}${r.dayMaster.element}(${r.dayMaster.yinYang}) 空亡:${r.kongwang.join('')}`,
58
109
  line('年柱', P.year), line('月柱', P.month), line('日柱', P.day), line('时柱', P.hour),
59
110
  `五行:${wuxing}`,
111
+ ...(palaces ? [palaces] : []),
60
112
  `日主强弱:${r.strength.level}(${r.strength.score}) ${r.strength.detail}`,
61
113
  ...gejuLine,
62
114
  `天干关系:${(r.relations && r.relations.stems.length) ? r.relations.stems.join(' ') : '—'}`,
63
115
  `地支关系:${(r.relations && r.relations.branches.length) ? r.relations.branches.join(' ') : '—'}`,
64
116
  shenshaLine,
65
- `真太阳时:${r.solarTime.trueSolarText}(${r.input.geo ? r.input.geo.note + ';' : ''}${r.solarTime.note})`,
117
+ `真太阳时:${r.solarTime.trueSolarText}(${r.input.geo ? r.input.geo.note + ';' : ''}${r.solarTime.note}) 晚子时:${r.input.lateZi ? '23:00 后算次日' : '23:00 后仍算当日'}`,
66
118
  `节气:上一节 ${r.jieqi.previous.name} ${r.jieqi.previous.time} 下一节 ${r.jieqi.next.name} ${r.jieqi.next.time}`,
67
119
  `大运:${r.dayun.rule}(${r.dayun.direction}),${r.dayun.startAge.years}岁${r.dayun.startAge.months}个月起运`,
68
120
  ` ${dayun}`,
@@ -113,15 +165,57 @@ function ziweiText(r) {
113
165
  ].filter(Boolean).join('\n');
114
166
  }
115
167
 
168
+ function qimenText(r) {
169
+ const P = r.palaces;
170
+ const cell = (x) => `${x.palace}宫 ${x.star || '—'}/${x.gate || '—'}/${x.god || '—'}${x.kong ? ' 空' : ''}${x.ma ? ' 马' : ''}`;
171
+ return [
172
+ `【四柱】${r.siZhu.year} ${r.siZhu.month} ${r.siZhu.day} ${r.siZhu.time}`,
173
+ `【局数】${r.ju} 节气 ${r.jieqi}(${r.jieqiTime})`,
174
+ `【值符】${r.zhiFu.star}(${r.zhiFu.gong}宫) 【值使】${r.zhiShi.gate}(${r.zhiShi.gong}宫) 旬首 ${r.xunshou}`,
175
+ `【空亡】${r.kongWang.join('') || '—'} 驿马 ${r.ma.zhi || '—'}(${r.ma.gong || '—'}宫)`,
176
+ `【九宫】${P.map(cell).join(' ')}`,
177
+ `※ 晚子时:${r.lateZiHourMode === 'next-day' ? '23:00 后归次日' : '23:00 后仍归当日'}(可用 lateZiHourMode / lateZi 指定)`,
178
+ `※ ${r.rules[r.rules.length - 1]}`,
179
+ ].join('\n');
180
+ }
181
+
182
+ function hepanText(r) {
183
+ if (r.chart === 'ziwei') {
184
+ const d = (x) => `${x.label}:${x.score >= 0 ? '+' : ''}${x.score} ${x.text}`;
185
+ return [
186
+ `【紫微合盘】${r.score.level}(${r.score.total}/100)`,
187
+ `你 命宫 ${r.a.soul.branch}(${r.a.soulStars.join('、') || '无主星'}) 夫妻 ${r.a.spouseStars.join('、') || '无主星'} ${r.a.bureau}`,
188
+ `对方 命宫 ${r.b.soul.branch}(${r.b.soulStars.join('、') || '无主星'}) 夫妻 ${r.b.spouseStars.join('、') || '无主星'} ${r.b.bureau}`,
189
+ ...r.dimensions.map(d),
190
+ ...r.interactions.map((h) => ` · ${h.text}`),
191
+ ...r.notes.map((n) => `※ ${n}`),
192
+ ].join('\n');
193
+ }
194
+ const d = (x) => `${x.label}:${x.score >= 0 ? '+' : ''}${x.score} ${x.text}`;
195
+ const solarLine = (side) => {
196
+ if (!side || !side.solarNote) return '—';
197
+ return `${side.longitude == null ? '未含经度修正' : `${side.longitude}°E`}(${side.solarNote})`;
198
+ };
199
+ return [
200
+ `【合盘】${r.score.level}(${r.score.total}/100)`,
201
+ `你 ${r.a.pillars.join(' ')} 日主 ${r.a.dayMaster} 日支 ${r.a.dayBranch} ${r.a.strength || ''}`,
202
+ `对方 ${r.b.pillars.join(' ')} 日主 ${r.b.dayMaster} 日支 ${r.b.dayBranch} ${r.b.strength || ''}`,
203
+ `真太阳时:你 ${solarLine(r.a)};对方 ${solarLine(r.b)}`,
204
+ ...r.dimensions.map(d),
205
+ ...r.notes.map((n) => `※ ${n}`),
206
+ ].join('\n');
207
+ }
208
+
116
209
  function daliurenText(r) {
117
210
  const sc = r.sanChuan;
118
211
  const one = (c) => `${c.zhi}(${c.jiang}${c.liuQin}${c.xunKong ? '空' : ''})`;
119
212
  return [
120
- `【日时】${r.dayGanZhi}日 ${r.hourZhi}时(${r.dayNight}贵) 月将 ${r.yueJiang.name}${r.yueJiang.zhi} 节气 ${r.jieqi.name}`,
213
+ `【日时】${r.dayGanZhi}日 ${r.hourZhi}时(${r.dayNight}贵) 月将 ${r.yueJiang.name}${r.yueJiang.zhi} 节气 ${r.jieqi.name} ${r.lateZi === false ? '晚子时不换日' : '晚子时换日'}`,
121
214
  `【三传】初 ${one(sc.chu)} → 中 ${one(sc.zhong)} → 末 ${one(sc.mo)}`,
122
215
  `【四课】${r.siKe.map((k) => `${k.position}课 ${k.shang}上${k.xia}下(${k.relation}·${k.jiang})`).join(' ')}`,
123
216
  `【天地盘】${r.pan.map((p) => `${p.di}:${p.tian}(${p.jiang})`).join(' ')}`,
124
217
  `【格局】${r.geJu.name}·${r.geJu.detail}`,
218
+ ...((r.shensha && r.shensha.length) ? [`【神煞】${r.shensha.map((x) => `${x.name}${x.branch}`).join(' ')}`] : []),
125
219
  ].join('\n');
126
220
  }
127
221
 
@@ -164,6 +258,7 @@ function liuyaoText(r) {
164
258
  const out = [
165
259
  `起卦:${r.cast.notation.join('、')}(自下而上)`,
166
260
  `日辰:${r.dayGanZhi.name}`,
261
+ r.xunShou ? `旬首:${r.xunShou} 旬空:${(r.xunKong || []).join('') || '—'}` : '',
167
262
  r.question ? `所问:${r.question}` : '',
168
263
  '本卦:',
169
264
  fmt(r.ben),
@@ -186,6 +281,14 @@ function jsonSafe(value) {
186
281
  */
187
282
  export function compute(args = {}) {
188
283
  const r = jsonSafe(computeRaw(args));
284
+ // 超出 ΔT 覆盖范围时把精度提示顶到摘要最前,别让远期盘悄悄出错
285
+ if (r && r.precision && r.precision.level === 'approx' && r.summary) {
286
+ r.summary = `⚠ ${r.precision.note}\n${r.summary}`;
287
+ }
288
+ // 没有经度就没有真太阳时:时柱可能整柱偏(海外出生尤其常见),必须显式告警而不是藏在括号里
289
+ if (r && (r.system === 'bazi' || r.system === 'ziwei') && r.input && r.input.longitude == null && r.summary) {
290
+ r.summary = `⚠ 未提供出生地或经度:按东八区平太阳时排盘,时柱可能与本命盘相差一个时辰;海外出生请先把当地时间换算成东八区标准时。\n${r.summary}`;
291
+ }
189
292
  // 显式要求时才附加解析模块,避免默认输出过长;「所问之事」与「询问方向」一并传进去
190
293
  if (args.withInterpret) {
191
294
  r.interpret = interpret(r, {
@@ -201,78 +304,190 @@ export function interpretText(i) {
201
304
  if (!i) return '';
202
305
  const out = [`【${i.title || '解析与详解'}】${i.ask && i.ask.echo ? i.ask.echo : ''}`];
203
306
  if (i.lead) out.push(i.lead);
307
+ // 解析生成失败时不能只返回一个空壳:盘面数据仍然可用,但要让调用方知道"这段没生成出来"
308
+ if (i.error) out.push(`⚠ 解析生成失败:${i.error}(上方盘面数据本身可用)`);
204
309
  for (const d of i.dimensions || []) {
205
- out.push(`${d.label}:${d.text}${d.advice ? ` 建议:${d.advice}` : ''}`);
310
+ const evidence = Array.isArray(d.evidence) && d.evidence.length ? `\n 依据:${d.evidence.join(';')}` : '';
311
+ out.push(`${d.label}:${d.text}${d.advice ? ` 建议:${d.advice}` : ''}${evidence}`);
206
312
  }
207
313
  for (const s of i.sections || []) out.push(`${s.label}:${s.text}`);
208
314
  if (i.disclaimer) out.push(i.disclaimer);
209
315
  return out.join('\n');
210
316
  }
211
317
 
212
- function computeRaw(args) {
318
+ const JU_METHODS = ['chaibu', 'chaoshen', 'zhirun'];
319
+ const TAROT_COUNTS = [1, 3, 5];
320
+ const LATE_ZI_MODES = ['same-day', 'next-day'];
321
+
322
+ /** asOf 只接受能解析成真实日期的字符串;非法值以前会静默回退到「此刻」 */
323
+ function checkAsOf(asOf) {
324
+ if (asOf === undefined || asOf === null || asOf === '') return undefined;
325
+ const parts = asOfParts(asOf);
326
+ if (!parts) throw fail('E_ASOF', `无法解析 asOf:「${asOf}」,请用 2026-10-22T16:00 这种格式`);
327
+ const { year, month, day } = parts;
328
+ if (!(month >= 1 && month <= 12) || !(day >= 1 && day <= daysInMonth(year, month))) {
329
+ throw fail('E_ASOF', `asOf 指向的日期不存在:${asOf}`);
330
+ }
331
+ return parts;
332
+ }
333
+
334
+ function computeRaw(args = {}) {
213
335
  const system = args.system;
214
336
  if (system === 'bazi') {
215
337
  const dt = parseDateTime(args.datetime);
338
+ const asOf = checkAsOf(args.asOf);
216
339
  const result = buildBazi({
217
340
  ...dt,
218
- gender: args.gender === 'female' ? 'female' : 'male',
341
+ gender: requireGender(args.gender),
219
342
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
220
343
  address: typeof args.address === 'string' ? args.address : undefined,
221
344
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
222
345
  lateZi: args.lateZi !== false,
223
346
  trueSolar: args.trueSolar !== false,
347
+ // 流年表默认从「今年」起排;要复现历史结论就把基准年钉死
348
+ nowYear: asOf ? asOf.year : undefined,
224
349
  });
225
- return { ...result, summary: baziText(result) };
350
+ return { ...result, ...(asOf ? { asOf } : {}), precision: dt.precision, summary: baziText(result) };
226
351
  }
227
352
  if (system === 'ziwei') {
228
353
  const dt = parseDateTime(args.datetime);
354
+ const asOf = checkAsOf(args.asOf);
229
355
  const result = buildZiwei({
230
356
  ...dt,
231
- gender: args.gender === 'female' ? 'female' : 'male',
357
+ gender: requireGender(args.gender),
232
358
  leapAsNext: args.leapAsNext === true,
233
359
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
234
360
  address: typeof args.address === 'string' ? args.address : undefined,
235
361
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
236
362
  trueSolar: args.trueSolar,
237
363
  // 流月/流日/流时的基准时刻:默认本机此刻,可用 asOf 指定以便复现
238
- asOf: args.asOf,
364
+ asOf,
239
365
  });
240
- return { ...result, summary: ziweiText(result) };
366
+ return { ...result, precision: dt.precision, summary: ziweiText(result) };
241
367
  }
242
368
  if (system === 'tarot') {
243
- // 抽牌不看时间:castAt 只记录此刻,避免误用出生时间
244
- const result = drawTarot({ count: Number(args.count) || 3, question: args.question });
245
- return { ...result, summary: tarotText(result) };
369
+ const count = args.count === undefined || args.count === null || args.count === '' ? 3 : Number(args.count);
370
+ if (!TAROT_COUNTS.includes(count)) {
371
+ throw fail('E_COUNT', `塔罗张数只能是 1 / 3 / 5,收到「${args.count}」`);
372
+ }
373
+ if (!isSeed(args.seed)) throw fail('E_INPUT', `seed 必须是数字,收到「${args.seed}」`);
374
+ const asOf = checkAsOf(args.asOf);
375
+ // 抽牌不看时间:castAt 只记录此刻(或 asOf),避免误用出生时间;给了 seed 就能复现同一次抽牌
376
+ const result = drawTarot({ count, question: args.question, at: args.asOf, random: randomOf(args.seed) });
377
+ if (args.seed !== undefined && args.seed !== null && args.seed !== '') result.seed = Number(args.seed);
378
+ return { ...result, ...(asOf ? { asOf } : {}), summary: tarotText(result) };
246
379
  }
247
380
  if (system === 'daliuren') {
248
381
  const dt = parseDateTime(args.datetime);
249
- const result = buildDaliuren({ ...dt, lateZi: args.lateZi !== false });
250
- return { ...result, summary: daliurenText(result) };
382
+ const lateZi = args.lateZi !== false;
383
+ const result = buildDaliuren({ ...dt, lateZi });
384
+ result.lateZi = lateZi;
385
+ return { ...result, precision: dt.precision, summary: daliurenText(result) };
386
+ }
387
+ if (system === 'qimen') {
388
+ const dt = parseDateTime(args.datetime);
389
+ if (args.juMethod !== undefined && !JU_METHODS.includes(args.juMethod)) {
390
+ throw fail('E_INPUT', `juMethod 只能是 chaibu / chaoshen / zhirun,收到「${args.juMethod}」`);
391
+ }
392
+ // 全插件只有"晚子时"这一件事,参数名不该按体系分裂:
393
+ // lateZiHourMode 是奇门的历史参数名,lateZi 是八字/六壬的;两者互为别名。
394
+ const mode = args.lateZiHourMode !== undefined
395
+ ? args.lateZiHourMode
396
+ : args.lateZi === true ? 'next-day' : args.lateZi === false ? 'same-day' : undefined;
397
+ if (mode !== undefined && !LATE_ZI_MODES.includes(mode)) {
398
+ throw fail('E_INPUT', `lateZiHourMode 只能是 same-day / next-day,收到「${mode}」`);
399
+ }
400
+ const lateZiHourMode = mode === 'next-day' ? 'next-day' : 'same-day';
401
+ const result = buildQimen({ ...dt, lateZiHourMode, juMethod: args.juMethod });
402
+ result.lateZiHourMode = lateZiHourMode;
403
+ return { ...result, summary: qimenText(result) };
404
+ }
405
+ if (system === 'hepan') {
406
+ const partner = args.partner;
407
+ if (!partner || typeof partner !== 'object') {
408
+ throw fail('E_PARTNER', '合盘需要 partner:{ datetime, gender, address? },其中 partner.datetime 是对方的公历出生时间');
409
+ }
410
+ if (!partner.datetime) {
411
+ throw fail('E_PARTNER', '缺少 partner.datetime(对方的公历出生时间),例如 partner: {"datetime":"1992-03-03T10:00","gender":"female"}');
412
+ }
413
+ const pa = parseDateTime(args.datetime);
414
+ const pb = parseDateTime(partner.datetime);
415
+ const chart = args.chart === 'ziwei' ? 'ziwei' : 'bazi';
416
+ // 性别决定双方大运/大限顺逆,两边都不能用默认值
417
+ const sideA = {
418
+ ...pa,
419
+ gender: requireGender(args.gender, '你(主方)'),
420
+ trueSolar: args.trueSolar,
421
+ lateZi: args.lateZi,
422
+ address: typeof args.address === 'string' ? args.address : undefined,
423
+ longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
424
+ geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
425
+ };
426
+ const sideB = {
427
+ ...pb,
428
+ gender: requireGender(partner.gender, '对方'),
429
+ trueSolar: partner.trueSolar,
430
+ lateZi: partner.lateZi,
431
+ address: typeof partner.address === 'string' ? partner.address : undefined,
432
+ longitude: typeof partner.longitude === 'number' ? partner.longitude : undefined,
433
+ geo: partner.geo && typeof partner.geo === 'object' ? partner.geo : undefined,
434
+ };
435
+ const result = chart === 'ziwei' ? buildZiweiHepan({ a: sideA, b: sideB }) : buildHepan({ a: sideA, b: sideB });
436
+ return { ...result, summary: hepanText(result) };
251
437
  }
252
438
  if (system === 'meihua') {
253
- const result = buildMeihua({
254
- mode: args.mode === 'number' ? 'number' : 'time',
255
- datetime: args.datetime,
256
- upper: args.upper,
257
- lower: args.lower,
258
- changing: args.changing,
259
- });
439
+ let result;
440
+ try {
441
+ result = buildMeihua({
442
+ mode: args.mode === 'number' ? 'number' : 'time',
443
+ datetime: args.datetime,
444
+ upper: args.upper,
445
+ lower: args.lower,
446
+ changing: args.changing,
447
+ });
448
+ } catch (e) {
449
+ throw fail('E_INPUT', `梅花起卦参数不完整:${e && e.message ? e.message : e}`);
450
+ }
260
451
  return { ...result, summary: meihuaText(result) };
261
452
  }
262
453
  if (system === 'liuyao') {
263
- const lines = Array.isArray(args.lines) && args.lines.length
264
- ? args.lines.map(Number)
265
- : tossLines();
454
+ if (!isSeed(args.seed)) throw fail('E_INPUT', `seed 必须是数字,收到「${args.seed}」`);
455
+ let lines;
456
+ if (args.lines === undefined || args.lines === null) {
457
+ // 没给爻值 = 随机摇卦;给了 seed 则同一次摇卦可复现
458
+ lines = tossLines(randomOf(args.seed));
459
+ } else {
460
+ if (!Array.isArray(args.lines)) {
461
+ throw fail('E_LINES', 'lines 必须是数组:自下而上 6 个爻值(6 老阴 / 7 少阳 / 8 少阴 / 9 老阳)');
462
+ }
463
+ if (args.lines.length !== 6) {
464
+ // 以前这里会静默改随机摇卦:用户手抖少填一个,拿到的却是一张"看起来正常"的随机盘
465
+ throw fail('E_LINES', `六爻需要正好 6 个爻值(自下而上),收到 ${args.lines.length} 个;留空则由引擎随机摇卦`);
466
+ }
467
+ lines = args.lines.map(Number);
468
+ }
266
469
  let dayGanZhi;
267
- if (args.datetime) {
268
- const dt = parseDateTime(args.datetime);
269
- const g = ganzhiName(dayGanzhiIndex(toJDN(dt.year, dt.month, dt.day)));
470
+ let asOf = null;
471
+ // 六神按「起卦日」的日干起:给了 datetime 用它,给了 asOf 用 asOf,都没有才回落到本机当天
472
+ const stamp = args.datetime ? parseDateTime(args.datetime) : checkAsOf(args.asOf);
473
+ if (stamp) {
474
+ if (!args.datetime) asOf = stamp;
475
+ const g = ganzhiName(dayGanzhiIndex(toJDN(stamp.year, stamp.month, stamp.day)));
270
476
  dayGanZhi = { stem: g.stem, branch: g.branch, name: g.name };
271
477
  }
272
- const result = buildLiuyao({ lines, question: args.question, dayGanZhi });
273
- return { ...result, summary: liuyaoText(result) };
478
+ let result;
479
+ try {
480
+ result = buildLiuyao({ lines, question: args.question, dayGanZhi });
481
+ } catch (e) {
482
+ throw fail('E_LINES', e && e.message ? e.message : e);
483
+ }
484
+ if (args.seed !== undefined && args.seed !== null && args.seed !== '') result.seed = Number(args.seed);
485
+ return { ...result, ...(asOf ? { asOf } : {}), summary: liuyaoText(result) };
274
486
  }
275
- throw new Error(`未知体系「${system}」,可选:bazi(四柱八字)、ziwei(紫微斗数)、liuyao(六爻)、meihua(梅花易数)、daliuren(大六壬)、tarot(塔罗)。`);
487
+ if (!system) {
488
+ throw fail('E_INPUT', '缺少 system,可选:bazi(四柱八字)、ziwei(紫微斗数)、liuyao(六爻)、meihua(梅花易数)、daliuren(大六壬)、qimen(奇门遁甲)、hepan(合盘)、tarot(塔罗)。');
489
+ }
490
+ throw fail('E_SYSTEM', `未知体系「${system}」,可选:bazi(四柱八字)、ziwei(紫微斗数)、liuyao(六爻)、meihua(梅花易数)、daliuren(大六壬)、qimen(奇门遁甲)、hepan(合盘)、tarot(塔罗)。`);
276
491
  }
277
492
 
278
493
  /** 给模型的紧凑文本(去掉大字段,保留摘要;带解析时附上解析) */
@@ -287,3 +502,12 @@ export function renderText(result) {
287
502
  export { geocodeAddress, resolveAddress };
288
503
 
289
504
  export { buildBazi, buildLiuyao, tossLines };
505
+
506
+
507
+
508
+
509
+
510
+
511
+
512
+
513
+