dsh-metaphysics 0.38.2 → 0.46.5

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.
@@ -3,6 +3,7 @@
3
3
  // 参照公开通行表实现(与常见八宅/玄空入门资料一致),年紫白用纯公式,不依赖任何第三方历法库。
4
4
  import { partsToJd, termInstantJd } from './calendar.js';
5
5
  import { applyDst } from './dst.js';
6
+ import { fail } from './fail.js';
6
7
 
7
8
  /** 九星名称表:数 → {name, color, element, star(北斗名), lucky}。 */
8
9
  export const NINE_STARS = {
@@ -56,7 +57,7 @@ const MOUNTAIN_INDEX = new Map(MOUNTAINS_24.map((x, i) => [x.m, i]));
56
57
  export function mountainInfo(name) {
57
58
  const i = MOUNTAIN_INDEX.get(name);
58
59
  if (i === undefined) {
59
- throw new Error(`坐向必须是 24 山之一(${MOUNTAINS_24.map((x) => x.m).join('')}),收到「${name}」——请按罗盘 24 山报坐或报向,不要自造方位词。`);
60
+ throw fail('E_INPUT', `坐向必须是 24 山之一(${MOUNTAINS_24.map((x) => x.m).join('')}),收到「${name}」——请按罗盘 24 山报坐或报向,不要自造方位词。`);
60
61
  }
61
62
  return { ...MOUNTAINS_24[i], index: i };
62
63
  }
@@ -143,6 +144,127 @@ export function yearlyFlying(year) {
143
144
 
144
145
  const pad2 = (n) => String(n).padStart(2, '0');
145
146
 
147
+ // ── 玄空飞星(下卦) ─────────────────────────────────────────────
148
+ // 口径:三元九运每运 20 年,1864 年起一运;运盘当运星入中顺飞。
149
+ // 山盘/向盘:取坐宫/向宫的运星入中,按「入中运星本宫同元龙」的阴阳定顺逆(五黄入中按本山阴阳)。
150
+ // 这一步是玄空挨星的关键:不是按坐山本身的阴阳,而是按入中运星原宫对应元龙的阴阳;
151
+ // 按本山阴阳会把旺山旺向算成每运固定的六局,与通行二十四山旺山旺向表对不上。
152
+
153
+ /** 三元九运:1864 起每 20 年一运,九运一轮(180 年)。 */
154
+ export function periodOfYear(year) {
155
+ const k = Math.floor((year - 1864) / 20);
156
+ const period = ((k % 9) + 9) % 9 + 1;
157
+ const start = 1864 + 20 * k;
158
+ return { period, start, end: start + 19, label: `${period}运(${start}–${start + 19})` };
159
+ }
160
+
161
+ /** 三元龙阴阳:天元/人元四隅为阳、四正为阴;地元四正为阳、四隅为阴。 */
162
+ const YANG_MOUNTAINS = new Set(['乾', '巽', '艮', '坤', '甲', '庚', '壬', '丙', '寅', '申', '巳', '亥']);
163
+ const YUAN_NAME = ['地元', '天元', '人元'];
164
+
165
+ /** 24 山 → 三元龙 + 阴阳(MOUNTAINS_24 每卦三山依次为 地元/天元/人元)。 */
166
+ export const MOUNTAIN_META = {};
167
+ MOUNTAINS_24.forEach((x, i) => {
168
+ MOUNTAIN_META[x.m] = { ...x, yuan: YUAN_NAME[i % 3], yinYang: YANG_MOUNTAINS.has(x.m) ? '阳' : '阴' };
169
+ });
170
+
171
+ /** 飞星轨迹:中 → 乾 → 兑 → 艮 → 离 → 坎 → 坤 → 震 → 巽(即洛书 5→6→7→8→9→1→2→3→4)。 */
172
+ const FLY_SEQ = ['中', '乾', '兑', '艮', '离', '坎', '坤', '震', '巽'];
173
+ const NUM_TO_GUA = ['坎', '坤', '震', '巽', '中', '乾', '兑', '艮', '离']; // 洛书数 1–9 本位卦
174
+ const YUAN_INDEX = { 地元: 0, 天元: 1, 人元: 2 };
175
+
176
+ const mountainsOfGua = (gua) => MOUNTAINS_24.filter((x) => x.gua === gua).map((x) => MOUNTAIN_META[x.m]);
177
+
178
+ /** 入中星按轨迹飞布:forward=true 顺飞,false 逆飞。返回 宫 → 星数。 */
179
+ function fly(center, forward) {
180
+ const out = {};
181
+ for (let i = 0; i < 9; i++) {
182
+ const v = ((center - 1 + (forward ? i : -i)) % 9 + 9) % 9 + 1;
183
+ out[FLY_SEQ[i]] = v;
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /** 入中运星定顺逆:取本宫(运星本位卦)同元龙之山的阴阳;五黄入中按本山阴阳。 */
189
+ function flightOf(mount, center) {
190
+ if (center === 5) {
191
+ return { forward: mount.yinYang === '阳', basis: `五黄入中,按${mount.m}本山${mount.yuan}${mount.yinYang}论` };
192
+ }
193
+ const home = NUM_TO_GUA[center - 1];
194
+ const hm = mountainsOfGua(home)[YUAN_INDEX[mount.yuan]];
195
+ return { forward: hm.yinYang === '阳', basis: `${center}${home}宫取${mount.yuan}${hm.m}(${hm.yinYang})` };
196
+ }
197
+
198
+ /** 通行二十四山旺山旺向表(每项含正反两向;一运、九运无局)。 */
199
+ export const WANG_SHAN_WANG_XIANG = {
200
+ 1: [],
201
+ 2: [['乾', '巽'], ['亥', '巳'], ['丑', '未']],
202
+ 3: [['卯', '酉'], ['乙', '辛'], ['辰', '戌']],
203
+ 4: [['艮', '坤'], ['寅', '申'], ['甲', '庚']],
204
+ 5: [['子', '午'], ['癸', '丁'], ['卯', '酉'], ['乙', '辛'], ['丑', '未'], ['辰', '戌']],
205
+ 6: [['艮', '坤'], ['寅', '申'], ['甲', '庚']],
206
+ 7: [['卯', '酉'], ['乙', '辛'], ['辰', '戌']],
207
+ 8: [['乾', '巽'], ['亥', '巳'], ['丑', '未']],
208
+ 9: [],
209
+ };
210
+
211
+ export const XUANKONG_SOURCES = [
212
+ '三元九运(1864 年起一运、每运 20 年,九运 180 年一轮)',
213
+ '玄空下卦挨星(运盘顺飞;山盘/向盘以入中运星本宫同元龙阴阳定顺逆,五黄入中按本山阴阳)',
214
+ '二十四山旺山旺向通行表(每项含正反两向;一运、九运无局)',
215
+ ];
216
+
217
+ export const XUANKONG_DISCLAIMER = '玄空下卦为传统通行口径的本地排盘,属民俗参考,不构成现场勘舆结论,仅供娱乐;替卦/兼向、年月日时飞星与形峦不在本工具范围内。';
218
+
219
+ /** 玄空下卦排盘:运盘 + 山盘 + 向盘 + 当运星 + 合十 + 表法旺山旺向。 */
220
+ export function xuankongChart(period, sitting, facing) {
221
+ const sit = MOUNTAIN_META[sitting];
222
+ const face = MOUNTAIN_META[facing];
223
+ const yun = fly(period, true);
224
+ const sCenter = yun[sit.gua];
225
+ const fCenter = yun[face.gua];
226
+ const sFly = flightOf(sit, sCenter);
227
+ const fFly = flightOf(face, fCenter);
228
+ const shan = fly(sCenter, sFly.forward);
229
+ const xiang = fly(fCenter, fFly.forward);
230
+ const palaces = PALACE_ORDER.map((palace, i) => ({
231
+ palace,
232
+ direction: palace === '中' ? '中宫' : PALACE_DIRECTION[palace],
233
+ luoshu: i + 1,
234
+ yun: yun[palace],
235
+ shan: shan[palace],
236
+ xiang: xiang[palace],
237
+ }));
238
+ const wheres = (map) => PALACE_ORDER.filter((p) => map[p] === period);
239
+ const pairs = WANG_SHAN_WANG_XIANG[period];
240
+ const hit = pairs.find(([a, b]) => (sit.m === a && face.m === b) || (sit.m === b && face.m === a));
241
+ const byChart = shan[sit.gua] === period && xiang[face.gua] === period;
242
+ const note = byChart && hit
243
+ ? '盘面与表法一致:本局到山到向。'
244
+ : byChart
245
+ ? '盘面到山到向,但不在通行表法所列之局(表法与盘面口径不同,并列不合并)。'
246
+ : hit
247
+ ? '通行表法列为旺山旺向,但本盘当运山星/向星未同时落坐向(表法与盘面口径不同,并列不合并)。'
248
+ : '本局既未到山到向,也不在表法旺山旺向之列。';
249
+ return {
250
+ palaces,
251
+ sitting: sit,
252
+ facing: face,
253
+ yun,
254
+ shan,
255
+ xiang,
256
+ yunCenter: period,
257
+ shanCenter: { star: sCenter, ...sFly },
258
+ xiangCenter: { star: fCenter, ...fFly },
259
+ dangYun: { shan: wheres(shan), xiang: wheres(xiang) },
260
+ heshi: {
261
+ shan: PALACE_ORDER.filter((p) => shan[p] + yun[p] === 10),
262
+ xiang: PALACE_ORDER.filter((p) => xiang[p] + yun[p] === 10),
263
+ },
264
+ wangShanWangXiang: { byChart, byTable: Boolean(hit), table: pairs.map(([a, b]) => `${a}${b}`), note },
265
+ };
266
+ }
267
+
146
268
  /** DST 校正出生日期(1986–1991),返回校正后的年月日。 */
147
269
  function civilDate(input) {
148
270
  const dst = input.dst === false
@@ -161,19 +283,21 @@ const DISCLAIMER = '八宅与飞星为传统通行口径的本地计算,属民
161
283
 
162
284
  /**
163
285
  * 风水主函数。
164
- * @param {{action?:'natal'|'advise', year?:number, month?:number, day?:number, gender?:'male'|'female',
165
- * fortuneYear?:number, sitting?:string, facing?:string, dst?:boolean}} input
286
+ * @param {{action?:'natal'|'advise'|'xuankong', year?:number, month?:number, day?:number, gender?:'male'|'female',
287
+ * fortuneYear?:number, sitting?:string, facing?:string, period?:number, dst?:boolean}} input
166
288
  * natal(默认):要生日 + 性别 + 流年(缺流年则只算命卦与八星)。
167
289
  * advise:要坐山或向山(24 山,给一即可);出生信息可选,给了才做命宅配。
290
+ * xuankong:要坐山或向山;运由 period(1–9)或 fortuneYear 定,缺省按九运并注明。
168
291
  */
169
292
  export function buildFengshui(input) {
170
293
  if (input.action === 'advise') return buildAdvise(input);
294
+ if (input.action === 'xuankong') return buildXuankong(input);
171
295
  const gender = input.gender;
172
296
  if (gender !== 'male' && gender !== 'female') {
173
- throw new Error('风水命卦需要性别:男女命卦算法不同,不可缺省');
297
+ throw fail('E_INPUT', '风水命卦需要性别:男女命卦算法不同,不可缺省');
174
298
  }
175
299
  if (!Number.isInteger(input.year) || !Number.isInteger(input.month) || !Number.isInteger(input.day)) {
176
- throw new Error('风水命卦需要完整出生年月日(公历)——只知道年份不够:命卦按立春界划分出生年');
300
+ throw fail('E_INPUT', '风水命卦需要完整出生年月日(公历)——只知道年份不够:命卦按立春界划分出生年');
177
301
  }
178
302
  const civil = civilDate(input);
179
303
  const guaYear = guaYearOf(civil.year, civil.month, civil.day);
@@ -191,7 +315,7 @@ export function buildFengshui(input) {
191
315
  fortuneYear,
192
316
  ...(civil.dst.applied ? { dst: civil.dst.note } : {}),
193
317
  ...(fortuneYear === null ? { note: '未给流年:未算年紫白飞星(需要时补 fortuneYear)。' } : {}),
194
- scope: '八宅命卦 + 大游年八星 + 年紫白;不含玄空飞星各运盘、不含形峦与现场勘舆。',
318
+ scope: '八宅命卦 + 大游年八星 + 年紫白;玄空飞星见 action=xuankong;不含形峦与现场勘舆。',
195
319
  },
196
320
  mingGua: {
197
321
  num,
@@ -208,22 +332,28 @@ export function buildFengshui(input) {
208
332
  };
209
333
  }
210
334
 
211
- /** 宅卦坐向 + 门主灶厕布局倾向 + 命宅配。 */
212
- function buildAdvise(input) {
335
+ /** 坐向解析:至少给坐或向;两者都给则必须对宫。 */
336
+ function resolveSittingFacing(input) {
213
337
  const hasSitting = typeof input.sitting === 'string' && input.sitting.trim() !== '';
214
338
  const hasFacing = typeof input.facing === 'string' && input.facing.trim() !== '';
215
339
  if (!hasSitting && !hasFacing) {
216
- throw new Error('缺少坐向:sitting(坐山)或 facing(向山)至少给一个——按罗盘 24 山报坐或报向,例如「坐北朝南」对应坐子向午');
340
+ throw fail('E_INPUT', '缺少坐向:sitting(坐山)或 facing(向山)至少给一个——按罗盘 24 山报坐或报向,例如「坐北朝南」对应坐子向午');
217
341
  }
218
342
  if (hasSitting && hasFacing) {
219
343
  const s = mountainInfo(input.sitting.trim());
220
344
  const f = mountainInfo(input.facing.trim());
221
345
  if ((s.index + 12) % 24 !== f.index) {
222
- throw new Error(`坐向不自洽:坐${s.m}(${s.deg}°)与向${f.m}(${f.deg}°)不是对宫(相差应 180°,实测 ${((f.deg - s.deg) + 360) % 360}°)——请核对罗盘读数。`);
346
+ throw fail('E_INPUT', `坐向不自洽:坐${s.m}(${s.deg}°)与向${f.m}(${f.deg}°)不是对宫(相差应 180°,实测 ${((f.deg - s.deg) + 360) % 360}°)——请核对罗盘读数。`);
223
347
  }
224
348
  }
225
349
  const sitting = hasSitting ? mountainInfo(input.sitting.trim()) : sittingFromFacing(input.facing.trim());
226
350
  const facing = MOUNTAINS_24[(sitting.index + 12) % 24];
351
+ return { sitting, facing };
352
+ }
353
+
354
+ /** 宅卦坐向 + 门主灶厕布局倾向 + 命宅配。 */
355
+ function buildAdvise(input) {
356
+ const { sitting, facing } = resolveSittingFacing(input);
227
357
  const houseGua = sitting.gua;
228
358
  const stars = eightStars(houseGua);
229
359
  const lucky = LUCKY_STARS.map((s) => stars[s]);
@@ -277,7 +407,7 @@ function buildAdvise(input) {
277
407
  facing: `${facing.m}山(罗盘 ${facing.deg}°)`,
278
408
  houseGua,
279
409
  houseGroup: `${guaGroup(houseGua)}宅`,
280
- scope: '门主灶厕走向按八宅通行口径给倾向,不含形峦、不含玄空飞星各运盘、不是现场勘舆结论。',
410
+ scope: '门主灶厕走向按八宅通行口径给倾向,玄空飞星见 action=xuankong;不含形峦、不是现场勘舆结论。',
281
411
  },
282
412
  houseStars: starChart,
283
413
  layout,
@@ -286,3 +416,59 @@ function buildAdvise(input) {
286
416
  disclaimer: DISCLAIMER,
287
417
  };
288
418
  }
419
+
420
+ /** 玄空下卦:坐向 + 运 → 九宫盘、当运星、合十、表法旺山旺向。 */
421
+ function buildXuankong(input) {
422
+ const { sitting, facing } = resolveSittingFacing(input);
423
+ let period;
424
+ let yearInfo = null;
425
+ let note;
426
+ if (input.period !== undefined && input.period !== null && !Number.isInteger(input.period)) {
427
+ throw fail('E_INPUT', `运必须是 1–9 的整数(三元九运),收到「${input.period}」——一运 1864 起、九运 2024–2043。`);
428
+ }
429
+ if (Number.isInteger(input.period)) {
430
+ if (input.period < 1 || input.period > 9) {
431
+ throw fail('E_INPUT', `运必须是 1–9 的整数(三元九运),收到「${input.period}」——一运 1864 起、九运 2024–2043。`);
432
+ }
433
+ period = input.period;
434
+ } else if (Number.isInteger(input.fortuneYear)) {
435
+ yearInfo = periodOfYear(input.fortuneYear);
436
+ period = yearInfo.period;
437
+ } else {
438
+ period = 9;
439
+ note = '未给运:默认按九运(2024–2043)排盘;可传 period(1–9)或 fortuneYear 指定。';
440
+ }
441
+ const chart = xuankongChart(period, sitting.m, facing.m);
442
+ const sitMeta = MOUNTAIN_META[sitting.m];
443
+ const faceMeta = MOUNTAIN_META[facing.m];
444
+ const center = (c) => ({ star: c.star, name: NINE_STARS[c.star].name, fly: c.forward ? '顺飞' : '逆飞', basis: c.basis });
445
+ const where = (list) => list.map((p) => `${p}宫${PALACE_DIRECTION[p]}`).join('、') || '无';
446
+ return {
447
+ system: 'fengshui',
448
+ meta: {
449
+ action: 'xuankong',
450
+ period,
451
+ periodLabel: yearInfo ? yearInfo.label : `${period}运`,
452
+ ...(yearInfo ? { year: input.fortuneYear, periodStart: yearInfo.start, periodEnd: yearInfo.end } : {}),
453
+ sitting: `${sitting.m}山(罗盘 ${sitting.deg}°)`,
454
+ facing: `${facing.m}山(罗盘 ${facing.deg}°)`,
455
+ sittingGua: sitting.gua,
456
+ facingGua: facing.gua,
457
+ sittingYuan: `${sitMeta.yuan}${sitMeta.yinYang}`,
458
+ facingYuan: `${faceMeta.yuan}${faceMeta.yinYang}`,
459
+ shanCenter: center(chart.shanCenter),
460
+ xiangCenter: center(chart.xiangCenter),
461
+ ...(note ? { note } : {}),
462
+ scope: '玄空下卦:运盘 + 山盘 + 向盘 + 当运星 + 合十 + 表法旺山旺向;不含替卦/兼向、不含年月日时飞星、不含形峦与现场勘舆。',
463
+ },
464
+ palaces: chart.palaces,
465
+ dangYun: {
466
+ shan: { palaces: chart.dangYun.shan, text: where(chart.dangYun.shan) },
467
+ xiang: { palaces: chart.dangYun.xiang, text: where(chart.dangYun.xiang) },
468
+ },
469
+ heshi: { shan: chart.heshi.shan, xiang: chart.heshi.xiang },
470
+ wangShanWangXiang: chart.wangShanWangXiang,
471
+ sources: XUANKONG_SOURCES,
472
+ disclaimer: XUANKONG_DISCLAIMER,
473
+ };
474
+ }
@@ -199,30 +199,54 @@ const SYSTEM_LABEL = {
199
199
  * 客户端把这段文字写进对话输入框并提交,由对话里的模型基于盘面回答。
200
200
  *
201
201
  * @param {object} r compute 的结果(至少要带 system 与 summary)
202
- * @param {{question?:string, maxChars?:number}} [opts]
202
+ * @param {{question?:string, maxChars?:number, interpret?:object}} [opts]
203
+ * interpret 传面板已算好的解析对象(it)时,把其中的白话结论一并交给模型,
204
+ * 让对话开场与面板里的「先答所问 / 说人话」是同一个口径;不传则不加这一段。
203
205
  * @returns {{text:string, question:string, system:string, label:string, chars:number, truncated:boolean}}
204
206
  */
205
207
  export function askPrompt(r, opts = {}) {
206
208
  const label = SYSTEM_LABEL[r && r.system] || (r && r.system) || '未知体系';
207
209
  const asked = String((opts && opts.question) || '').trim();
208
- const question = asked || '请先总览这张盘,指出最值得注意的一两处,并说明各自的依据。';
210
+ const question = asked || '请先用一句白话讲清这张盘的主线,再指出最值得注意的一两处,并说明每处的具体依据。';
209
211
  const limit = Number.isFinite(opts && opts.maxChars) && opts.maxChars >= 400 ? opts.maxChars : 3000;
210
212
  const raw = String((r && r.summary) || '').trim();
211
213
  const truncated = raw.length > limit;
212
214
  const summary = truncated ? `${raw.slice(0, limit)}…(摘要已截断)` : raw;
213
215
 
216
+ // 面板里已经算过一遍解析:直接取「直答 / 说人话」,避免模型开场另起一套说法
217
+ const it = (opts && typeof opts.interpret === 'object' && opts.interpret)
218
+ || (r && typeof r.interpret === 'object' && r.interpret) || null;
219
+ const answer = String((it && it.answer) || '').trim();
220
+ const plain = String((it && it.plain) || '').trim();
221
+ const draft = [];
222
+ if (answer) draft.push(`直答:${answer}`);
223
+ if (plain) draft.push(`说人话:${plain}`);
224
+
214
225
  const lines = [
215
226
  '【命理追问】下面这张盘是命理插件刚排好的,请就着它回答我的问题;不用重新排盘,盘面字段已经够用。',
216
227
  `体系:${label}`,
217
228
  `所问:${question}`,
218
229
  '',
230
+ ...(draft.length ? [
231
+ '——开场结论(插件已按同一套规则算好,开场先沿用这句;措辞可改,口径别换)——',
232
+ ...draft,
233
+ '',
234
+ ] : []),
219
235
  '——盘面摘要——',
220
236
  summary || '(这张盘没有摘要字段,请先让我重新排一次。)',
221
237
  '',
222
238
  '——回答要求——',
223
- '1) 尽量引用到具体的柱、宫或爻,再说判断,别只给结论;',
224
- '2) 盘面里没有依据的地方直接说「这盘里看不出来」,不要补外部假设;',
225
- '3) 用概率和倾向的说法,别给绝对化的断言,也别把传统说法当成事实。',
239
+ '读者不懂命理。按下面的骨架答;任何一句他看不懂,就算没写完:',
240
+ draft.length
241
+ ? '1) 开头一句结论:先沿用上面的「开场结论」,再用一句不用术语的话说清这张盘的主线——只读这一句也该有收获;'
242
+ : '1) 开头一句结论:不用术语,一句话说清这张盘的主线,只读这一句也该有收获;',
243
+ '2) 判断最多两条,按重要性排序,每条分四行写:',
244
+ ' 盘面|只引盘面已有的字(具体的柱、宫或爻、字段名),不掺解释;',
245
+ ' 白话|把上一行的术语当场翻成日常话——调候 / 化气 / 通关 / 空亡 / 争合 这类词必须落地,一个词只翻一次,不连续堆三个以上;',
246
+ ' 倾向|说「更可能……」,讲清它在现实里大概意味着什么,用概率语气;',
247
+ ' 边界|这条在什么情况下不成立;盘面没给的,直接写「这盘里看不出来」;',
248
+ '3) 收尾一句「如果只记一件事」:给一条能核对、能动手的观察点,并说明它出自上面哪条盘面依据;',
249
+ '4) 别把传统说法当成已证实的事实:不下绝对断言,也不用「肯定」「绝对」这类词,更不写「格局很好」「运程不顺」这种没有信息的空话。',
226
250
  ];
227
251
  const text = lines.join('\n');
228
252
  return { text, question, system: (r && r.system) || '', label, chars: text.length, truncated };
package/engine/geju.js CHANGED
@@ -198,6 +198,36 @@ function specialPatterns(pillars, dayStem, monthBranch, strength, wuxing, gods)
198
198
  return out;
199
199
  }
200
200
 
201
+ // ── 通关 ────────────────────────────────────────────────────
202
+ // 滴天髓「通关」只给取象(织女、牛郎),这里把结构说清:
203
+ // 用神与忌神两行在局中都真有分量、且相克时,五行里恰有一行能接上——克方所生、且能生被克方。
204
+ // 只报这一行在局中的分量,不判「关通了就吉」。
205
+ const TONGGUAN_MIN = 1.5;
206
+ export function tongguanOf(favor = [], avoid = [], wuxing = {}) {
207
+ const w = (el) => Math.round((Number(wuxing[el]) || 0) * 10) / 10;
208
+ for (const use of favor) {
209
+ if (w(use) < TONGGUAN_MIN) continue;
210
+ for (const foe of avoid) {
211
+ if (OVERCOMES[foe] !== use || w(foe) < TONGGUAN_MIN) continue;
212
+ const bridge = GENERATES[foe];
213
+ if (!bridge || GENERATES[bridge] !== use) continue;
214
+ const weight = w(bridge);
215
+ const bridgeIsAvoid = avoid.includes(bridge) && !favor.includes(bridge);
216
+ const level = bridgeIsAvoid ? '为忌' : weight >= TONGGUAN_MIN ? '可通' : weight > 0 ? '关弱' : '未通';
217
+ return {
218
+ use, foe, bridge,
219
+ weight,
220
+ present: weight > 0,
221
+ bridgeIsAvoid,
222
+ level,
223
+ usable: level === '可通',
224
+ note: '相战=用神与忌神两行各在局中 ≥1.5 分且相克;通关一行=克方所生、又能生用神的那一行(五行里唯一),只报局中分量,不据此断吉凶',
225
+ };
226
+ }
227
+ }
228
+ return null;
229
+ }
230
+
201
231
  // ── 用神 / 喜忌 ──────────────────────────────────────────────
202
232
 
203
233
 
@@ -232,6 +262,7 @@ function yongshenOf(dayStem, monthBranch, strength, wuxing, branches = []) {
232
262
  };
233
263
  const favorTenGods = Array.from(new Set(favor.flatMap((el) => expand[roleName(el)] || [])));
234
264
  const avoidTenGods = Array.from(new Set(avoid.flatMap((el) => expand[roleName(el)] || [])));
265
+ const tongguan = tongguanOf(favor, avoid, wuxing);
235
266
 
236
267
  const detail = [
237
268
  `日主${dayStem}${me},${strength.level}(${strength.score} 分):`,
@@ -242,6 +273,15 @@ function yongshenOf(dayStem, monthBranch, strength, wuxing, branches = []) {
242
273
  : '身中和,先取调候与通关,喜忌随大运流年动态变化。',
243
274
  `调候:${climate.label}(${(climate.evidence || []).join('、')})${climate.need ? `,取${climate.need.stem}${climate.need.element},忌${climate.avoidElement}` : ',气候平和'}。`,
244
275
  qt ? `穷通宝鉴:${qt.month}${dayStem}${me},用神${qt.yong.join('、')}(首取${qt.primary})。` : '',
276
+ tongguan
277
+ ? `通关:忌${tongguan.foe}克用${tongguan.use},五行以${tongguan.bridge}为关;局中${tongguan.level === '可通'
278
+ ? `${tongguan.bridge} ${tongguan.weight} 分,关可通`
279
+ : tongguan.level === '关弱'
280
+ ? `仅 ${tongguan.bridge} ${tongguan.weight} 分,关弱`
281
+ : tongguan.level === '为忌'
282
+ ? `${tongguan.bridge} ${tongguan.weight} 分但此行为忌,通关力打折`
283
+ : `未见${tongguan.bridge},关未通`}(只作结构提示,不据此断吉凶)。`
284
+ : '',
245
285
  arb.text,
246
286
  arb.clash ? '取舍:本盘按「调候优先、扶抑次之」取用;若你更认同扶抑派,可把调候当环境偏好,强弱判断不受影响。' : '',
247
287
  ].filter(Boolean).join('');
@@ -258,6 +298,7 @@ function yongshenOf(dayStem, monthBranch, strength, wuxing, branches = []) {
258
298
  tiaohou: { ...climate, ...arb, element: climate.need ? climate.need.element : null },
259
299
  climate,
260
300
  qt,
301
+ tongguan,
261
302
  detail,
262
303
  };
263
304
  }
package/engine/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // 统一入口:解析参数、分派到各体系、生成可读摘要
2
2
  import { buildBazi } from './bazi.js';
3
- import { buildLiuyao, tossLines, bodyLabels, yongshenOf, yongshenStrength, hexagramStrength, jinTuiPower } from './liuyao.js';
4
- import { buildZiwei, BRIGHTNESS, flowOf, asOfParts } from './ziwei.js';
3
+ import { buildLiuyao, tossLines, bodyLabels, yongshenOf, yongshenStrength, fushenAccess, hexagramStrength, jinTuiPower } from './liuyao.js';
4
+ import { buildZiwei, BRIGHTNESS, flowOf, asOfParts, ZIWEI_SCHOOL_DEFAULTS } from './ziwei.js';
5
5
  import { drawTarot } from './tarot.js';
6
6
  import { buildMeihua } from './meihua.js';
7
7
  import { buildDaliuren } from './daliuren.js';
@@ -55,6 +55,21 @@ function requireGender(value, who = '') {
55
55
  throw fail('E_INPUT', `${who ? who + '的' : ''}gender 必须是 male 或 female${got};性别决定大运/大限顺逆,不能靠默认值`);
56
56
  }
57
57
 
58
+ const ZIWEI_SCHOOL_KEYS = [
59
+ 'brightnessSchool', 'gengYearSihua', 'tianmaSchool', 'tiankongSchool', 'jiekongSchool',
60
+ 'kuiyueSchool', 'tianshiTianshang', 'changshengSchool', 'lateZishi',
61
+ ];
62
+
63
+ /** 紫微流派参数原样透传;非法值交给引擎按默认口径回退 */
64
+ function ziweiSchoolArgs(args) {
65
+ const out = {};
66
+ for (const key of ZIWEI_SCHOOL_KEYS) {
67
+ if (args[key] !== undefined) out[key] = args[key];
68
+ }
69
+ if (args.schools && typeof args.schools === 'object') out.schools = args.schools;
70
+ return out;
71
+ }
72
+
58
73
  /** 解析 'YYYY-MM-DDTHH:mm' / 'YYYY-MM-DD HH:mm' / 'YYYY-MM-DD' */
59
74
  export function parseDateTime(text) {
60
75
  if (typeof text !== 'string' || !text.trim()) {
@@ -148,7 +163,23 @@ function baziText(r) {
148
163
  }
149
164
 
150
165
  function ziweiText(r) {
151
- const lunarText = `${r.lunar.year}年${r.lunar.isLeap ? '闰' : ''}${r.lunar.month}月${r.lunar.day}日`;
166
+ // 闰月:日期按「闰X月」原样写;换算过起盘月时再附一句,免得把 21 日写成「闰5月21日」
167
+ const rawMonth = r.lunar.monthRaw || r.lunar.month;
168
+ const lunarText = `${r.lunar.year}年${r.lunar.isLeap ? `闰${rawMonth}` : r.lunar.month}月${r.lunar.day}日`
169
+ + (r.lunar.isLeap && r.lunar.month !== rawMonth ? `(按${r.lunar.month}月起盘)` : '');
170
+ // 只在显式改动流派时补一行,默认盘不增加噪音
171
+ const schoolLine = (() => {
172
+ if (!r.schools) return '';
173
+ const labels = {
174
+ brightnessSchool: '亮度', gengYearSihua: '庚干四化', tianmaSchool: '天马', tiankongSchool: '天空',
175
+ jiekongSchool: '截空', kuiyueSchool: '魁钺', tianshiTianshang: '天伤天使',
176
+ changshengSchool: '长生', lateZishi: '晚子时',
177
+ };
178
+ const diff = Object.entries(labels)
179
+ .filter(([key]) => r.schools[key] && r.schools[key] !== ZIWEI_SCHOOL_DEFAULTS[key])
180
+ .map(([key, label]) => `${label} ${r.schools[key]}`);
181
+ return diff.length ? `【流派】${diff.join(' ')}` : '';
182
+ })();
152
183
  const starText = (stars) => (stars.length
153
184
  ? stars.map((s) => `${s.name}${s.mutagen ? `化${s.mutagen}` : ''}`).join(' ')
154
185
  : '—');
@@ -199,6 +230,7 @@ function ziweiText(r) {
199
230
  `【命宫】${r.soul.branch}(${r.soul.ganzhi}·${r.soul.nayin}) 【身宫】${r.body.branch}(落${r.body.lands}) 【五行局】${r.bureau.name}`,
200
231
  `【紫微】${r.ziwei} 【天府】${r.tianfu} 【命主】${r.lifeMaster} 【身主】${r.bodyMaster}`,
201
232
  `【大限】${r.daxian.rule},${r.daxian.startAge}岁起 【小限】${r.xiaoxianRule.direction}`,
233
+ schoolLine,
202
234
  `【昌曲】文昌${r.changqu.wenchang.branch}(${r.changqu.wenchang.palace}${r.changqu.wenchang.mutagen ? '·化' + r.changqu.wenchang.mutagen : ''}) 文曲${r.changqu.wenqu.branch}(${r.changqu.wenqu.palace}${r.changqu.wenqu.mutagen ? '·化' + r.changqu.wenqu.mutagen : ''})`,
203
235
  `   ${r.changqu.relation} 入命三方:${r.changqu.inSoulSanfang ? '是' : '否'} 吸收面:${r.changqu.absorb.label}(${r.changqu.absorb.reason})`,
204
236
  flyLine,
@@ -355,6 +387,11 @@ function liuyaoText(r) {
355
387
  const m = strength.month && strength.month.branch ? `月建 ${strength.month.branch}(${strength.month.element}) ` : '';
356
388
  out.push(`用神旺衰:${m}${strength.labels.join(' · ')}`);
357
389
  }
390
+ // 伏神出伏条件:只在用神不上卦时出现;只归条件档,不给应期
391
+ const chufu = r.fushenAccess;
392
+ if (chufu && Array.isArray(chufu.labels) && chufu.labels.length) {
393
+ out.push(`伏神出伏:${chufu.labels.join(' · ')}`);
394
+ }
358
395
  } else {
359
396
  out.push('用神:未取(未指明所问之事;用「询问方向」指定后即按六亲取用)');
360
397
  }
@@ -421,7 +458,7 @@ export function compute(args = {}) {
421
458
  };
422
459
  const asOf = checkAsOf(args.asOf);
423
460
  const other = r.system === 'bazi'
424
- ? buildZiwei({ ...common, leapAsNext: args.leapAsNext === true, asOf })
461
+ ? buildZiwei({ ...common, leapMonth: args.leapMonth, leapAsNext: args.leapAsNext === true, ...ziweiSchoolArgs(args), asOf })
425
462
  : buildBazi({ ...common, lateZi: args.lateZi !== false, nowYear: asOf ? asOf.year : undefined });
426
463
  r.cross = crossCheck(r.system === 'bazi' ? r : other, r.system === 'ziwei' ? r : other);
427
464
  if (r.interpret) {
@@ -474,8 +511,9 @@ export function compute(args = {}) {
474
511
  export function interpretText(i) {
475
512
  if (!i) return '';
476
513
  const out = [`【${i.title || '解析与详解'}】${i.ask && i.ask.echo ? i.ask.echo : ''}`];
477
- // 直答放在最前:先回答「你问的事」,再说依据,避免读者读到最后才知道结论
478
- if (i.answer) out.push(`▶ 先答所问:${i.answer}`);
514
+ // 直答放在最前:先回答「你问的事」,再说依据,避免读者读到最后才知道结论;
515
+ // 没填问题时就不该喊「答所问」——改为「先看结论」,否则是一句假话
516
+ if (i.answer) out.push(`▶ ${i.ask && i.ask.question ? '先答所问' : '先看结论'}:${i.answer}`);
479
517
  if (i.plain) out.push(`▶ 说人话:${i.plain}`);
480
518
  if (i.lead) out.push(i.lead);
481
519
  // 一页读懂:报告顶部的五条结论,先给读的人一个「整体形状」
@@ -551,7 +589,9 @@ function computeRaw(args = {}) {
551
589
  const result = buildZiwei({
552
590
  ...dt,
553
591
  gender: requireGender(args.gender),
592
+ leapMonth: args.leapMonth,
554
593
  leapAsNext: args.leapAsNext === true,
594
+ ...ziweiSchoolArgs(args),
555
595
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
556
596
  address: typeof args.address === 'string' ? args.address : undefined,
557
597
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
@@ -702,6 +742,9 @@ function computeRaw(args = {}) {
702
742
  if (ug.liuqin) {
703
743
  const strength = yongshenStrength(result, ug, { monthBranch, dayBranch: result.dayGanZhi.branch });
704
744
  if (strength) result.useGodStrength = strength;
745
+ // 伏神出伏条件:飞伏生克叠月建日辰,只判条件、不出应期
746
+ const chufu = fushenAccess(result, ug, { monthBranch, dayBranch: result.dayGanZhi.branch });
747
+ if (chufu) result.fushenAccess = chufu;
705
748
  }
706
749
  if (args.seed !== undefined && args.seed !== null && args.seed !== '') result.seed = Number(args.seed);
707
750
  return { ...result, ...(asOf ? { asOf } : {}), summary: liuyaoText(result) };
@@ -802,7 +845,17 @@ function computeRaw(args = {}) {
802
845
  return { ...result, precision: dt.precision, summary: trendText(result) };
803
846
  }
804
847
  if (system === 'fengshui') {
805
- const action = args.action === 'advise' ? 'advise' : 'natal';
848
+ const action = args.action === 'advise' ? 'advise' : args.action === 'xuankong' ? 'xuankong' : 'natal';
849
+ if (action === 'xuankong') {
850
+ const result = buildFengshui({
851
+ action,
852
+ sitting: args.sitting,
853
+ facing: args.facing,
854
+ ...(args.period !== undefined ? { period: args.period } : {}),
855
+ ...(Number.isInteger(args.fortuneYear) ? { fortuneYear: args.fortuneYear } : {}),
856
+ });
857
+ return { ...result, summary: fengshuiText(result) };
858
+ }
806
859
  if (action === 'advise') {
807
860
  const result = buildFengshui({
808
861
  action,
@@ -841,6 +894,8 @@ function computeRaw(args = {}) {
841
894
  givenName: args.givenName,
842
895
  limit: args.limit,
843
896
  charElements: args.charElements && typeof args.charElements === 'object' ? args.charElements : undefined,
897
+ strokeOverrides: args.strokeOverrides && typeof args.strokeOverrides === 'object' ? args.strokeOverrides : undefined,
898
+ surnameStrokes: args.surnameStrokes && typeof args.surnameStrokes === 'object' ? args.surnameStrokes : undefined,
844
899
  ...birth,
845
900
  ...(args.gender === 'male' || args.gender === 'female' ? { gender: args.gender } : {}),
846
901
  ...(args.datetime ? {
@@ -1019,6 +1074,25 @@ function trendText(r) {
1019
1074
 
1020
1075
  /** 风水文本 */
1021
1076
  function fengshuiText(r) {
1077
+ if (r.meta.action === 'xuankong') {
1078
+ const p = r.meta.period;
1079
+ const cell = (palace) => {
1080
+ const x = r.palaces.find((y) => y.palace === palace);
1081
+ return `${palace}${x.yun}/${x.shan}/${x.xiang}`;
1082
+ };
1083
+ const row = (names) => names.map(cell).join(' ');
1084
+ const w = r.wangShanWangXiang;
1085
+ return [
1086
+ `【玄空】${r.meta.periodLabel} 坐 ${r.meta.sitting} 向 ${r.meta.facing}`,
1087
+ `【入中】山盘 ${r.meta.shanCenter.star}${r.meta.shanCenter.name}(${r.meta.shanCenter.fly},${r.meta.shanCenter.basis});向盘 ${r.meta.xiangCenter.star}${r.meta.xiangCenter.name}(${r.meta.xiangCenter.fly},${r.meta.xiangCenter.basis})`,
1088
+ `【九宫】${row(['巽', '离', '坤'])}|${row(['震', '中', '兑'])}|${row(['艮', '坎', '乾'])}`,
1089
+ `【当运】山星${p}到${r.dangYun.shan.text};向星${p}到${r.dangYun.xiang.text}`,
1090
+ `【表法】${w.table.length ? w.table.join('、') : '无'};${w.note}`,
1091
+ `【合十】山星合十 ${r.heshi.shan.join('、') || '无'};向星合十 ${r.heshi.xiang.join('、') || '无'}`,
1092
+ ...(r.meta.note ? [`※ ${r.meta.note}`] : []),
1093
+ `※ ${r.disclaimer}`,
1094
+ ].join('\n');
1095
+ }
1022
1096
  if (r.meta.action === 'advise') {
1023
1097
  const match = r.mingMatch
1024
1098
  ? [`【命宅配】${r.mingMatch.text}`]
@@ -1051,7 +1125,11 @@ function nameText(r) {
1051
1125
  if (r.action === 'generate') {
1052
1126
  return [
1053
1127
  `【候选名】姓 ${r.surname}(${r.gender},喜用 ${r.bazi.favorable.join('、')})${r.bazi.basis}`,
1054
- ...r.candidates.map((c) => `${c.rank}. ${c.name} ${c.chars.map((x) => `${x.char}(${x.element})${x.meaning}`).join(' ')}`),
1128
+ ...r.candidates.map((c) => {
1129
+ const wu = c.wuge && c.wuge.available ? ` 五格 ${c.wuge.summary}` : '';
1130
+ const ph = c.phonetics && c.phonetics.available ? ` 音韵 ${c.phonetics.level}(${c.phonetics.tonePattern})` : '';
1131
+ return `${c.rank}. ${c.name} ${c.chars.map((x) => `${x.char}(${x.element})${x.meaning}`).join(' ')}${wu}${ph}`;
1132
+ }),
1055
1133
  ...r.notes.map((n) => `※ ${n}`),
1056
1134
  ].join('\n');
1057
1135
  }
@@ -1064,10 +1142,20 @@ function nameText(r) {
1064
1142
  : '';
1065
1143
  return `· ${c.char}:${el}|${fit}|${c.meaning}${sug}`;
1066
1144
  };
1145
+ const wu = r.wuge;
1146
+ const wugeLine = wu && wu.available
1147
+ ? `【五格】${wu.strokes.map((x) => `${x.char}${x.strokes}画`).join('+')}|${wu.summary}|三才 ${wu.sancai.text}`
1148
+ : `【五格】${wu ? wu.reason : '未算'}`;
1149
+ const ph = r.phonetics;
1150
+ const phLine = ph && ph.available
1151
+ ? `【音韵】${ph.chars.map((x) => `${x.char}${x.py}${x.tone}`).join(' ')}|声调 ${ph.tonePattern}|${ph.level}${ph.issues.length ? `:${ph.issues.map((i) => i.text).join(';')}` : ''}`
1152
+ : `【音韵】${ph ? ph.reason : '未算'}`;
1067
1153
  return [
1068
1154
  `【名字评估】${r.fullName}`,
1069
1155
  ...r.chars.map(charLine),
1070
1156
  r.bazi ? `【八字】日主 ${r.bazi.dayMaster && r.bazi.dayMaster.stem ? `${r.bazi.dayMaster.stem}(${r.bazi.dayMaster.element})` : r.bazi.dayMaster},旺衰 ${r.bazi.strength.level}(${r.bazi.strength.score}),${r.bazi.basis}` : '',
1157
+ wugeLine,
1158
+ phLine,
1071
1159
  `【结论】${r.verdict}`,
1072
1160
  ...r.notes.map((n) => `※ ${n}`),
1073
1161
  ].filter(Boolean).join('\n');