dsh-metaphysics 0.14.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/diff.js ADDED
@@ -0,0 +1,197 @@
1
+ // 口径差异:同一组参数在不同流派口径下排出的盘,逐字段比对
2
+ //
3
+ // 用途:口径(晚子时换日、真太阳时、拆补/超神接气/置闰、闰月归月)是命理圈争执最多的地方。
4
+ // 多数工具切换后只给新结果,用户不知道「哪里变了」。这里把变化摊开:
5
+ // 变化字段路径 + 旧值 → 新值 + 一句话总结。
6
+ //
7
+ // 只做结构化比对,不改动任何引擎的输出。
8
+
9
+ /** 比对时忽略的字段:文案、时间戳、口径回显、内部儒略日数字 */
10
+ const IGNORE_KEYS = new Set([
11
+ 'summary', 'interpret', 'castAt', 'asOf', 'precision', 'disclaimer', 'note',
12
+ 'trueSolarText', 'civilText', 'offsetMinutes',
13
+ 'input', // 只是把调用参数回显出来,不是盘面变化
14
+ 'jd', // 内部儒略日,人类不可读;对外展示用 time 等格式化字段
15
+ ]);
16
+
17
+ const isObject = (v) => v !== null && typeof v === 'object';
18
+
19
+ /**
20
+ * 结构化差异
21
+ * @returns {{path:string, from:any, to:any}[]}
22
+ */
23
+ export function diffObjects(a, b, path = '', out = []) {
24
+ if (a === b) return out;
25
+ if (!isObject(a) || !isObject(b)) {
26
+ out.push({ path: path || '(root)', from: a, to: b });
27
+ return out;
28
+ }
29
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
30
+ for (const k of keys) {
31
+ if (IGNORE_KEYS.has(k)) continue;
32
+ const p = path ? `${path}.${k}` : k;
33
+ const va = a[k];
34
+ const vb = b[k];
35
+ if (isObject(va) && isObject(vb)) {
36
+ diffObjects(va, vb, p, out);
37
+ } else if (JSON.stringify(va) !== JSON.stringify(vb)) {
38
+ out.push({ path: p, from: va, to: vb });
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** 路径 → 盘面栏位名;未命中细分规则时退回所属大类 */
45
+ const LABELS = [
46
+ [/^pillars\.year\.ganzhi$/, '年柱'],
47
+ [/^pillars\.month\.ganzhi$/, '月柱'],
48
+ [/^pillars\.day\.ganzhi$/, '日柱'],
49
+ [/^pillars\.hour\.ganzhi$/, '时柱'],
50
+ [/^pillars\.\w+\.hidden/, '藏干'],
51
+ [/^pillars\.\w+\.stemTenGod$/, '十神'],
52
+ [/^pillars\.\w+\.kongwang/, '空亡'],
53
+ [/^pillars\.\w+\.dishi$|^pillars\.\w+\.zizuo$/, '十二长生'],
54
+ [/^pillars\.\w+\.nayin$/, '纳音'],
55
+ [/^dayMaster\./, '日主'],
56
+ [/^palaces\./, '命身宫'],
57
+ [/^dayun\.list\.(\d+)\.ganzhi$/, '大运干支'],
58
+ [/^dayun\.startAge\./, '起运时间'],
59
+ [/^dayun\./, '大运评分'],
60
+ [/^liunian\./, '流年'],
61
+ [/^jieqi\./, '节气边界'],
62
+ [/^solarTime\./, '真太阳时'],
63
+ [/^dayGanZhi$/, '日辰'],
64
+ [/^hourZhi$/, '时支'],
65
+ [/^yueJiang\./, '月将'],
66
+ [/^ju(Num|Name)?$/, '局数'],
67
+ [/^xunKong$/, '旬空'],
68
+ [/^lines\.(\d+)\./, '爻'],
69
+ [/^shensha\./, '神煞'],
70
+ [/^frame\./, '格局用神'],
71
+ [/^strength\./, '旺衰'],
72
+ [/^wuxing\./, '五行计数'],
73
+ [/^relations\./, '干支关系'],
74
+ [/^input\./, '输入回显'],
75
+ ];
76
+
77
+ /** 大类:用于把几百处连带变化归成几句话 */
78
+ const CATEGORY = [
79
+ [/^pillars\./, '四柱'],
80
+ [/^shensha\./, '神煞'],
81
+ [/^dayun\./, '大运'],
82
+ [/^liunian\./, '流年'],
83
+ [/^frame\./, '格局用神'],
84
+ [/^strength\./, '旺衰'],
85
+ [/^wuxing\./, '五行计数'],
86
+ [/^dayMaster\./, '日主'],
87
+ [/^relations\./, '干支关系'],
88
+ [/^palaces\./, '命身宫'],
89
+ [/^jieqi\./, '节气'],
90
+ [/^solarTime\./, '真太阳时'],
91
+ [/^input\./, '输入回显'],
92
+ [/^lines\./, '爻'],
93
+ ];
94
+
95
+ /**
96
+ * 根因字段:真正被口径直接改动的干支/局数/爻。
97
+ * 其余几百处都是从这里派生出去的——把这层关系讲清楚,才是「口径对照」的价值。
98
+ */
99
+ const ROOT = [
100
+ [/^pillars\.(year|month|day|hour)\.ganzhi$/, '柱'],
101
+ [/^dayGanZhi$/, '日辰'],
102
+ [/^hourZhi$/, '时支'],
103
+ [/^ju(Num|Name)?$/, '局数'],
104
+ [/^lines\.\d+\.(ganzhi|yinYang|name)$/, '爻'],
105
+ [/^yueJiang\.(name|zhi)$/, '月将'],
106
+ ];
107
+
108
+ export function labelOf(path) {
109
+ for (const [re, label] of LABELS) {
110
+ if (re.test(path)) return label;
111
+ }
112
+ return categoryOf(path) || path;
113
+ }
114
+
115
+ export function categoryOf(path) {
116
+ for (const [re, cat] of CATEGORY) {
117
+ if (re.test(path)) return cat;
118
+ }
119
+ return '';
120
+ }
121
+
122
+ export function rootOf(path) {
123
+ for (const [re, label] of ROOT) {
124
+ if (re.test(path)) return label;
125
+ }
126
+ return '';
127
+ }
128
+
129
+ /** 自然排序:行运类按序号,其余按字符串 */
130
+ export function sortDiffs(diffs) {
131
+ return [...diffs].sort((x, y) => x.path.localeCompare(y.path, 'zh-Hans-CN', { numeric: true }));
132
+ }
133
+
134
+ /**
135
+ * 把一组变化归纳成因果式一句话:
136
+ * 「根因:日柱 丙子 → 乙亥;连带影响 四柱、神煞、大运、流年 等 6 类(共 407 处)」
137
+ */
138
+ export function summarize(diffs) {
139
+ if (!diffs.length) return '本盘不受此口径影响:所有字段一致。';
140
+ const roots = diffs.filter((d) => rootOf(d.path));
141
+ const cats = [...new Set(diffs.map((d) => categoryOf(d.path)).filter(Boolean))];
142
+ const head = roots.length
143
+ ? `根因 ${roots.length} 处:` + roots.slice(0, 4)
144
+ .map((d) => `${labelOf(d.path)} ${fmt(d.from)}→${fmt(d.to)}`).join('、')
145
+ + (roots.length > 4 ? ' 等' : '')
146
+ : '无单一根因';
147
+ const tail = cats.length
148
+ ? `;连带影响 ${cats.slice(0, 6).join('、')}${cats.length > 6 ? ' 等' : ''} ${cats.length} 类,共 ${diffs.length} 处`
149
+ : `;共 ${diffs.length} 处`;
150
+ return head + tail + '。';
151
+ }
152
+
153
+ const fmt = (v) => {
154
+ if (v === undefined) return '—';
155
+ if (v === null) return 'null';
156
+ if (typeof v === 'string') return v;
157
+ if (Array.isArray(v)) return v.join('') || '[]';
158
+ if (typeof v === 'object') return JSON.stringify(v);
159
+ return String(v);
160
+ };
161
+
162
+ /**
163
+ * 文本版对照:先列根因,再列连带变化(默认最多 12 处,其余按类归并)
164
+ */
165
+ export function renderDiff(diffs, { title = '', limit = 12 } = {}) {
166
+ const lines = [];
167
+ if (title) lines.push(`【${title}】`);
168
+ lines.push(summarize(diffs));
169
+ if (!diffs.length) return lines.join('\n');
170
+
171
+ const roots = sortDiffs(diffs.filter((d) => rootOf(d.path)));
172
+ const rest = sortDiffs(diffs.filter((d) => !rootOf(d.path)));
173
+
174
+ if (roots.length) {
175
+ lines.push('');
176
+ lines.push(' 根因(口径直接改动):');
177
+ for (const d of roots) {
178
+ lines.push(` ${labelOf(d.path).padEnd(6)} ${fmt(d.from)} → ${fmt(d.to)}`);
179
+ }
180
+ }
181
+ if (rest.length) {
182
+ lines.push('');
183
+ lines.push(` 连带变化(${rest.length} 处,展示前 ${Math.min(limit, rest.length)} 处):`);
184
+ for (const d of rest.slice(0, limit)) {
185
+ lines.push(` ${labelOf(d.path).padEnd(6)} ${fmt(d.from)} → ${fmt(d.to)}`);
186
+ }
187
+ if (rest.length > limit) {
188
+ const byCat = {};
189
+ for (const d of rest.slice(limit)) {
190
+ const c = categoryOf(d.path) || '其他';
191
+ byCat[c] = (byCat[c] || 0) + 1;
192
+ }
193
+ lines.push(' 其余:' + Object.entries(byCat).map(([c, n]) => `${c} ${n} 处`).join('、'));
194
+ }
195
+ }
196
+ return lines.join('\n');
197
+ }
package/engine/fail.js ADDED
@@ -0,0 +1,13 @@
1
+ // 统一错误工厂:带错误码的 Error,工具层据此把「参数错误」转成可执行提示,
2
+ // 而不是把内部堆栈直出给模型。引擎各模块都从这里取 fail,避免各自造一个。
3
+
4
+ /**
5
+ * @param {string} code 错误码:E_INPUT / E_RANGE / E_LINES / E_COUNT / E_ASOF / E_PARTNER / E_SYSTEM
6
+ * @param {string} message 面向调用方的中文说明(含修复提示)
7
+ * @returns {Error & { code: string }}
8
+ */
9
+ export const fail = (code, message) => {
10
+ const e = new Error(message);
11
+ e.code = code;
12
+ return e;
13
+ };
package/engine/geo.js CHANGED
@@ -143,7 +143,7 @@ const DETAIL = /(区|县|镇|乡|村|小区|街道|路|号|巷|弄|组|队)/;
143
143
 
144
144
  /** OpenStreetMap Nominatim:免密钥、支持中文。失败返回 null。
145
145
  * UA 按 OSM 使用政策要求写明应用与联系方式(改版本时同步这里)。 */
146
- const USER_AGENT = 'dsh-metaphysics/0.14.3 (+https://github.com/xuanfu-web/dsh-metaphysics)';
146
+ const USER_AGENT = 'dsh-metaphysics/0.15.0 (+https://github.com/xuanfu-web/dsh-metaphysics)';
147
147
 
148
148
  async function onlineLookup(text, { timeoutMs = 2500 } = {}) {
149
149
  if (typeof fetch !== 'function') return null;
package/engine/hepan.js CHANGED
@@ -1,144 +1,147 @@
1
- // 合盘(合婚):日主关系 + 夫妻宫关系 + 五行互补。
2
- // 口径:日主取五行生克(双向平均),夫妻宫取日支的六合/三合/冲/害/刑/破,
3
- // 五行互补看「一方喜用是否正是另一方所旺」。各派取法不同,这里只给通用版结论。
4
- import { buildBazi } from './bazi.js';
5
- import { STEMS, BRANCHES } from './calendar.js';
6
-
7
- const WX_GAN = { 甲: '木', 乙: '木', 丙: '火', 丁: '火', 戊: '土', 己: '土', 庚: '金', 辛: '金', 壬: '水', 癸: '水' };
8
- const WX_ZHI = { 子: '水', 丑: '土', 寅: '木', 卯: '木', 辰: '土', 巳: '火', 午: '火', 未: '土', 申: '金', 酉: '金', 戌: '土', 亥: '水' };
9
- const SHENG = { 木: '火', 火: '土', 土: '金', 金: '水', 水: '木' };
10
- const KE = { 木: '土', 土: '水', 水: '火', 火: '金', 金: '木' };
11
-
12
- // 地支两两关系(只取合、冲、害、刑、破;三合按半合算)
13
- const LIU_HE = [['子', '丑'], ['寅', '亥'], ['卯', '戌'], ['辰', '酉'], ['巳', '申'], ['午', '未']];
14
- const CHONG = [['子', '午'], ['丑', '未'], ['寅', '申'], ['卯', '酉'], ['辰', '戌'], ['巳', '亥']];
15
- const HAI = [['子', '未'], ['丑', '午'], ['寅', '巳'], ['卯', '辰'], ['申', '亥'], ['酉', '戌']];
16
- const PO = [['子', '酉'], ['卯', '午'], ['辰', '丑'], ['未', '戌'], ['寅', '亥'], ['巳', '申']];
17
- const SAN_HE = [['申', '子', '辰'], ['寅', '午', '戌'], ['巳', '酉', '丑'], ['亥', '卯', '未']];
18
- const XING = [['寅', '巳', '申'], ['丑', '戌', '未'], ['子', '卯']];
19
-
20
- const pairIn = (list, a, b) => list.some((g) => g.includes(a) && g.includes(b) && a !== b);
21
-
22
- function zhiRelation(a, b) {
23
- if (a === b) return { name: '同支', score: 0, text: '日支相同,性情相近,容易互相理解,也容易同时卡在同一处' };
24
- if (pairIn(LIU_HE, a, b)) return { name: '六合', score: 20, text: '日支六合,相处自然、彼此补位' };
25
- for (const g of SAN_HE) {
26
- if (g.includes(a) && g.includes(b)) return { name: '半合', score: 14, text: '日支同属三合局,易有共同语言' };
27
- }
28
- if (pairIn(CHONG, a, b)) return { name: '相冲', score: -15, text: '日支相冲,节奏与需求容易错位,需要刻意协调' };
29
- if (pairIn(XING, a, b)) {
30
- for (const g of XING) {
31
- if (g.includes(a) && g.includes(b)) return { name: '相刑', score: -10, text: '日支相刑,摩擦多起于细节,宜少用指责' };
32
- }
33
- }
34
- if (pairIn(HAI, a, b)) return { name: '相害', score: -9, text: '日支相害,容易因第三方或误解生嫌隙' };
35
- if (pairIn(PO, a, b)) return { name: '相破', score: -5, text: '日支相破,小事易起波澜' };
36
- return { name: '无直接关系', score: 4, text: '日支之间没有直接刑冲合害,关系好坏主要看经营' };
37
- }
38
-
39
- /** 两个五行之间:对方对我 */
40
- function wxToMe(other, me) {
41
- if (other === me) return { name: '比和', score: 13, text: '两人日主同五行,气味相投,也容易一起犯同样的毛病' };
42
- if (SHENG[other] === me) return { name: '生我', score: 20, text: '对方五行生我,容易得到照顾与助力' };
43
- if (SHENG[me] === other) return { name: '我生', score: 10, text: '我生对方,多为付出的一方,久了要留意消耗' };
44
- if (KE[me] === other) return { name: '我克', score: 5, text: '我克对方,主导感较强,需留意对方的空间' };
45
- return { name: '克我', score: -12, text: '对方克我,压力感偏重,宜把话说开' };
46
- }
47
-
48
- /** 合盘主函数 */
49
- export function buildHepan(input) {
50
- const A = buildBazi({ ...input.a, gender: input.a.gender });
51
- const B = buildBazi({ ...input.b, gender: input.b.gender });
52
-
53
- const aStem = A.pillars.day.stem;
54
- const bStem = B.pillars.day.stem;
55
- const aWx = WX_GAN[aStem];
56
- const bWx = WX_GAN[bStem];
57
- const aZhi = A.pillars.day.branch;
58
- const bZhi = B.pillars.day.branch;
59
-
60
- // ① 日主
61
- const aToB = wxToMe(bWx, aWx); // 以 A 为主体看 B
62
- const bToA = wxToMe(aWx, bWx);
63
- const stemScore = (aToB.score + bToA.score) / 2;
64
-
65
- // ② 夫妻宫(日支)
66
- const gong = zhiRelation(aZhi, bZhi);
67
-
68
- // ③ 五行互补:一方喜用是否正是另一方所旺
69
- const favorA = (A.frame && A.frame.yongshen ? A.frame.yongshen.favor : []).slice();
70
- const favorB = (B.frame && B.frame.yongshen ? B.frame.yongshen.favor : []).slice();
71
- const wxCount = (r) => {
72
- const m = {};
73
- for (const k of ['year', 'month', 'day', 'hour']) {
74
- m[WX_GAN[r.pillars[k].stem]] = (m[WX_GAN[r.pillars[k].stem]] || 0) + 1;
75
- m[WX_ZHI[r.pillars[k].branch]] = (m[WX_ZHI[r.pillars[k].branch]] || 0) + 1;
76
- }
77
- return m;
78
- };
79
- const wxA = wxCount(A);
80
- const wxB = wxCount(B);
81
- const strongOf = (m) => Object.entries(m).sort((x, y) => y[1] - x[1])[0] || ['', 0];
82
- const [strongA] = strongOf(wxA);
83
- const [strongB] = strongOf(wxB);
84
- const bFeedsA = favorA.includes(strongB);
85
- const aFeedsB = favorB.includes(strongA);
86
- let compScore = 0;
87
- const compNotes = [];
88
- if (bFeedsA) { compScore += 12; compNotes.push(`对方的旺行 ${strongB} 正是你的喜用`); }
89
- if (aFeedsB) { compScore += 12; compNotes.push(`你的旺行 ${strongA} 正是对方的喜用`); }
90
- if (!bFeedsA && !aFeedsB) { compScore += 3; compNotes.push('双方的旺行都不是对方的喜用,互补性一般,靠沟通补'); }
91
- // 一方缺的五行正好是对方所旺,也算补
92
- const missingA = ['木', '火', '土', '金', '水'].filter((w) => !wxA[w]);
93
- const missingB = ['木', '火', '土', '金', '水'].filter((w) => !wxB[w]);
94
- if (missingA.includes(strongB)) { compScore += 4; compNotes.push(`你命局缺 ${strongB},对方正好偏旺`); }
95
- if (missingB.includes(strongA)) { compScore += 4; compNotes.push(`对方命局缺 ${strongA},你正好偏旺`); }
96
-
97
- // ④ 综合
98
- const raw = stemScore + gong.score + compScore; // 理论区间约 -27 ~ 56
99
- const pct = Math.max(0, Math.min(100, Math.round(((raw + 27) / 83) * 100)));
100
- const level = pct >= 75 ? '契合度较高' : pct >= 55 ? '中等偏顺' : pct >= 40 ? '需要磨合' : '差异较大';
101
-
102
- const dim = (key, label, score, text, extra) => ({ key, label, score: Math.round(score), text, ...(extra || {}) });
103
-
104
- return {
105
- system: 'hepan',
106
- a: {
107
- gender: A.input.gender,
108
- pillars: ['year', 'month', 'day', 'hour'].map((k) => A.pillars[k].ganzhi),
109
- dayMaster: `${aStem}${aWx}`,
110
- dayBranch: aZhi,
111
- strength: A.strength ? A.strength.level : null,
112
- favor: favorA.join(''),
113
- wuxing: wxA,
114
- // 真太阳时口径要能被看到:合盘与单盘用同一套修正,否则同一个人会出现两套四柱
115
- longitude: A.input.longitude ?? null,
116
- trueSolar: A.input.trueSolar !== false,
117
- solarNote: A.solarTime ? A.solarTime.note : '',
118
- summary: A.summary,
119
- },
120
- b: {
121
- gender: B.input.gender,
122
- pillars: ['year', 'month', 'day', 'hour'].map((k) => B.pillars[k].ganzhi),
123
- dayMaster: `${bStem}${bWx}`,
124
- dayBranch: bZhi,
125
- strength: B.strength ? B.strength.level : null,
126
- favor: favorB.join(''),
127
- wuxing: wxB,
128
- longitude: B.input.longitude ?? null,
129
- trueSolar: B.input.trueSolar !== false,
130
- solarNote: B.solarTime ? B.solarTime.note : '',
131
- summary: B.summary,
132
- },
133
- score: { total: pct, level },
134
- dimensions: [
135
- dim('stem', '日主关系', stemScore, `你的日主 ${aStem}${aWx},对方 ${bStem}${bWx}。${aToB.text};${bToA.text}。`),
136
- dim('gong', '夫妻宫(日支)', gong.score, `${gong.name}:${gong.text}。你日支 ${aZhi},对方日支 ${bZhi}。`),
137
- dim('wuxing', '五行互补', compScore, compNotes.join(';') + '。'),
138
- ],
139
- notes: [
140
- '合盘看的是相处方式与互补点,不决定关系成败;现实里的沟通与选择分量更重。',
141
- `口径:日主取五行生克双向平均,夫妻宫取日支关系,五行互补看「一方喜用是否为另一方所旺」。`,
142
- ],
143
- };
144
- }
1
+ // 合盘(合婚):日主关系 + 夫妻宫关系 + 五行互补。
2
+ // 口径:日主取五行生克(双向平均),夫妻宫取日支的六合/三合/冲/害/刑/破,
3
+ // 五行互补看「一方喜用是否正是另一方所旺」。各派取法不同,这里只给通用版结论。
4
+ import { buildBazi } from './bazi.js';
5
+ import { STEMS, BRANCHES } from './calendar.js';
6
+ import { assertCivil } from './validate.js';
7
+ import { SANHE_GROUPS } from './sanhe.js';
8
+
9
+ const WX_GAN = { 甲: '木', 乙: '木', 丙: '火', 丁: '火', 戊: '土', 己: '土', 庚: '金', 辛: '金', 壬: '水', 癸: '水' };
10
+ const WX_ZHI = { 子: '水', 丑: '土', 寅: '木', 卯: '木', 辰: '土', 巳: '火', 午: '火', 未: '土', 申: '金', 酉: '金', 戌: '土', 亥: '水' };
11
+ const SHENG = { 木: '火', 火: '土', 土: '金', 金: '水', 水: '木' };
12
+ const KE = { 木: '土', 土: '水', 水: '火', 火: '金', 金: '木' };
13
+
14
+ // 地支两两关系(只取合、冲、害、刑、破;三合按半合算)
15
+ const LIU_HE = [['子', '丑'], ['寅', '亥'], ['卯', '戌'], ['辰', '酉'], ['巳', '申'], ['午', '未']];
16
+ const CHONG = [['子', '午'], ['丑', '未'], ['寅', '申'], ['卯', '酉'], ['辰', '戌'], ['巳', '亥']];
17
+ const HAI = [['子', '未'], ['丑', '午'], ['寅', '巳'], ['卯', '辰'], ['申', '亥'], ['酉', '戌']];
18
+ const PO = [['子', '酉'], ['卯', '午'], ['辰', '丑'], ['未', '戌'], ['寅', '亥'], ['巳', '申']];
19
+ const XING = [['寅', '巳', '申'], ['丑', '戌', '未'], ['子', '卯']];
20
+
21
+ const pairIn = (list, a, b) => list.some((g) => g.includes(a) && g.includes(b) && a !== b);
22
+
23
+ function zhiRelation(a, b) {
24
+ if (a === b) return { name: '同支', score: 0, text: '日支相同,性情相近,容易互相理解,也容易同时卡在同一处' };
25
+ if (pairIn(LIU_HE, a, b)) return { name: '六合', score: 20, text: '日支六合,相处自然、彼此补位' };
26
+ if (SANHE_GROUPS.some((g) => g.branches.includes(a) && g.branches.includes(b))) {
27
+ return { name: '半合', score: 14, text: '日支同属三合局,易有共同语言' };
28
+ }
29
+ if (pairIn(CHONG, a, b)) return { name: '相冲', score: -15, text: '日支相冲,节奏与需求容易错位,需要刻意协调' };
30
+ if (pairIn(XING, a, b)) {
31
+ for (const g of XING) {
32
+ if (g.includes(a) && g.includes(b)) return { name: '相刑', score: -10, text: '日支相刑,摩擦多起于细节,宜少用指责' };
33
+ }
34
+ }
35
+ if (pairIn(HAI, a, b)) return { name: '相害', score: -9, text: '日支相害,容易因第三方或误解生嫌隙' };
36
+ if (pairIn(PO, a, b)) return { name: '相破', score: -5, text: '日支相破,小事易起波澜' };
37
+ return { name: '无直接关系', score: 4, text: '日支之间没有直接刑冲合害,关系好坏主要看经营' };
38
+ }
39
+
40
+ /** 两个五行之间:对方对我 */
41
+ function wxToMe(other, me) {
42
+ if (other === me) return { name: '比和', score: 13, text: '两人日主同五行,气味相投,也容易一起犯同样的毛病' };
43
+ if (SHENG[other] === me) return { name: '生我', score: 20, text: '对方五行生我,容易得到照顾与助力' };
44
+ if (SHENG[me] === other) return { name: '我生', score: 10, text: '我生对方,多为付出的一方,久了要留意消耗' };
45
+ if (KE[me] === other) return { name: '我克', score: 5, text: '我克对方,主导感较强,需留意对方的空间' };
46
+ return { name: '克我', score: -12, text: '对方克我,压力感偏重,宜把话说开' };
47
+ }
48
+
49
+ /** 合盘主函数 */
50
+ export function buildHepan(input) {
51
+ assertCivil(input && input.a, 'a');
52
+ assertCivil(input && input.b, 'b');
53
+ const A = buildBazi({ ...input.a, gender: input.a.gender });
54
+ const B = buildBazi({ ...input.b, gender: input.b.gender });
55
+
56
+ const aStem = A.pillars.day.stem;
57
+ const bStem = B.pillars.day.stem;
58
+ const aWx = WX_GAN[aStem];
59
+ const bWx = WX_GAN[bStem];
60
+ const aZhi = A.pillars.day.branch;
61
+ const bZhi = B.pillars.day.branch;
62
+
63
+ // ① 日主
64
+ const aToB = wxToMe(bWx, aWx); // 以 A 为主体看 B
65
+ const bToA = wxToMe(aWx, bWx);
66
+ const stemScore = (aToB.score + bToA.score) / 2;
67
+
68
+ // ② 夫妻宫(日支)
69
+ const gong = zhiRelation(aZhi, bZhi);
70
+
71
+ // ③ 五行互补:一方喜用是否正是另一方所旺
72
+ const favorA = (A.frame && A.frame.yongshen ? A.frame.yongshen.favor : []).slice();
73
+ const favorB = (B.frame && B.frame.yongshen ? B.frame.yongshen.favor : []).slice();
74
+ const wxCount = (r) => {
75
+ const m = {};
76
+ for (const k of ['year', 'month', 'day', 'hour']) {
77
+ m[WX_GAN[r.pillars[k].stem]] = (m[WX_GAN[r.pillars[k].stem]] || 0) + 1;
78
+ m[WX_ZHI[r.pillars[k].branch]] = (m[WX_ZHI[r.pillars[k].branch]] || 0) + 1;
79
+ }
80
+ return m;
81
+ };
82
+ const wxA = wxCount(A);
83
+ const wxB = wxCount(B);
84
+ const strongOf = (m) => Object.entries(m).sort((x, y) => y[1] - x[1])[0] || ['', 0];
85
+ const [strongA] = strongOf(wxA);
86
+ const [strongB] = strongOf(wxB);
87
+ const bFeedsA = favorA.includes(strongB);
88
+ const aFeedsB = favorB.includes(strongA);
89
+ let compScore = 0;
90
+ const compNotes = [];
91
+ if (bFeedsA) { compScore += 12; compNotes.push(`对方的旺行 ${strongB} 正是你的喜用`); }
92
+ if (aFeedsB) { compScore += 12; compNotes.push(`你的旺行 ${strongA} 正是对方的喜用`); }
93
+ if (!bFeedsA && !aFeedsB) { compScore += 3; compNotes.push('双方的旺行都不是对方的喜用,互补性一般,靠沟通补'); }
94
+ // 一方缺的五行正好是对方所旺,也算补
95
+ const missingA = ['木', '火', '土', '金', '水'].filter((w) => !wxA[w]);
96
+ const missingB = ['木', '火', '土', '金', '水'].filter((w) => !wxB[w]);
97
+ if (missingA.includes(strongB)) { compScore += 4; compNotes.push(`你命局缺 ${strongB},对方正好偏旺`); }
98
+ if (missingB.includes(strongA)) { compScore += 4; compNotes.push(`对方命局缺 ${strongA},你正好偏旺`); }
99
+
100
+ // ④ 综合
101
+ const raw = stemScore + gong.score + compScore; // 理论区间约 -27 ~ 56
102
+ const pct = Math.max(0, Math.min(100, Math.round(((raw + 27) / 83) * 100)));
103
+ const level = pct >= 75 ? '契合度较高' : pct >= 55 ? '中等偏顺' : pct >= 40 ? '需要磨合' : '差异较大';
104
+
105
+ const dim = (key, label, score, text, extra) => ({ key, label, score: Math.round(score), text, ...(extra || {}) });
106
+
107
+ return {
108
+ system: 'hepan',
109
+ a: {
110
+ gender: A.input.gender,
111
+ pillars: ['year', 'month', 'day', 'hour'].map((k) => A.pillars[k].ganzhi),
112
+ dayMaster: `${aStem}${aWx}`,
113
+ dayBranch: aZhi,
114
+ strength: A.strength ? A.strength.level : null,
115
+ favor: favorA.join(''),
116
+ wuxing: wxA,
117
+ // 真太阳时口径要能被看到:合盘与单盘用同一套修正,否则同一个人会出现两套四柱
118
+ longitude: A.input.longitude ?? null,
119
+ trueSolar: A.input.trueSolar !== false,
120
+ solarNote: A.solarTime ? A.solarTime.note : '',
121
+ summary: A.summary,
122
+ },
123
+ b: {
124
+ gender: B.input.gender,
125
+ pillars: ['year', 'month', 'day', 'hour'].map((k) => B.pillars[k].ganzhi),
126
+ dayMaster: `${bStem}${bWx}`,
127
+ dayBranch: bZhi,
128
+ strength: B.strength ? B.strength.level : null,
129
+ favor: favorB.join(''),
130
+ wuxing: wxB,
131
+ longitude: B.input.longitude ?? null,
132
+ trueSolar: B.input.trueSolar !== false,
133
+ solarNote: B.solarTime ? B.solarTime.note : '',
134
+ summary: B.summary,
135
+ },
136
+ score: { total: pct, level },
137
+ dimensions: [
138
+ dim('stem', '日主关系', stemScore, `你的日主 ${aStem}${aWx},对方 ${bStem}${bWx}。${aToB.text};${bToA.text}。`),
139
+ dim('gong', '夫妻宫(日支)', gong.score, `${gong.name}:${gong.text}。你日支 ${aZhi},对方日支 ${bZhi}。`),
140
+ dim('wuxing', '五行互补', compScore, compNotes.join(';') + '。'),
141
+ ],
142
+ notes: [
143
+ '合盘看的是相处方式与互补点,不决定关系成败;现实里的沟通与选择分量更重。',
144
+ `口径:日主取五行生克双向平均,夫妻宫取日支关系,五行互补看「一方喜用是否为另一方所旺」。`,
145
+ ],
146
+ };
147
+ }