dsh-metaphysics 0.62.1 → 0.62.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +2 -2
  3. package/client.almanac.js +69 -65
  4. package/client.bazi.js +122 -118
  5. package/client.chenggu.js +69 -65
  6. package/client.convert.js +71 -67
  7. package/client.daliuren.js +119 -115
  8. package/client.fengshui.js +68 -64
  9. package/client.hepan.js +123 -119
  10. package/client.js +192 -192
  11. package/client.liuyao.js +121 -117
  12. package/client.mbti.js +69 -65
  13. package/client.name.js +69 -65
  14. package/client.numerology.js +68 -64
  15. package/client.qimen.js +124 -120
  16. package/client.star.js +69 -65
  17. package/client.tarot.js +120 -116
  18. package/client.trend.js +113 -109
  19. package/client.xiaoliuren.js +78 -74
  20. package/client.zeri.js +68 -64
  21. package/client.ziwei.js +123 -119
  22. package/docs/assets/verification-badge.svg +6 -6
  23. package/engine/almanac.js +10 -6
  24. package/engine/bazi.js +10 -6
  25. package/engine/bool.js +195 -0
  26. package/engine/calendar.js +10 -2
  27. package/engine/chenggu.js +2 -1
  28. package/engine/convert.js +2 -1
  29. package/engine/daliuren.js +2 -1
  30. package/engine/diff.js +5 -3
  31. package/engine/fengshui.js +33 -12
  32. package/engine/geju.js +16 -3
  33. package/engine/geo.js +2 -1
  34. package/engine/hepan.js +4 -3
  35. package/engine/index.js +202 -72
  36. package/engine/liuyao.js +24 -5
  37. package/engine/mbti.js +6 -1
  38. package/engine/meihua.js +21 -7
  39. package/engine/name.js +20 -12
  40. package/engine/random.js +17 -3
  41. package/engine/rectify.js +4 -3
  42. package/engine/tarot.js +22 -8
  43. package/engine/trace.js +2 -1
  44. package/engine/trend.js +7 -3
  45. package/engine/xiaoliuren.js +2 -1
  46. package/engine/ziwei.js +4 -3
  47. package/index.js +367 -42
  48. package/package.json +1 -1
  49. package//345/221/275/347/220/206/346/216/222/347/233/230.html +629 -160
@@ -1,13 +1,13 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="470" height="28" role="img" aria-label="公开对拍:0 处不符|128 例 · 5 剔除 · 312 紫微 · 20 奇门 · 12 交运">
2
- <title>公开对拍:0 处不符|128 例 · 5 剔除 · 312 紫微 · 20 奇门 · 12 交运</title>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="455" height="28" role="img" aria-label="公开对拍:0 处不符|128 例 · 5 剔除 · 127 未核 · 312 紫微 · 20 奇门 · 12 交运">
2
+ <title>公开对拍:0 处不符|128 例 · 5 剔除 · 127 未核 · 312 紫微 · 20 奇门 · 12 交运</title>
3
3
  <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
4
- <rect width="470" height="28" rx="4" fill="#555"/>
5
- <rect x="62" width="408" height="28" rx="4" fill="#1f6f3f"/>
6
- <rect x="62" width="408" height="28" fill="url(#s)"/>
4
+ <rect width="455" height="28" rx="4" fill="#555"/>
5
+ <rect x="62" width="393" height="28" rx="4" fill="#1f6f3f"/>
6
+ <rect x="62" width="393" height="28" fill="url(#s)"/>
7
7
  <rect width="66" height="28" rx="4" fill="#555"/>
8
8
  <rect x="62" width="4" height="28" fill="#555"/>
9
9
  <g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
10
10
  <text x="33" y="18">对拍</text>
11
- <text x="266" y="18">0 处不符|128 例 · 5 剔除 · 312 紫微 · 20 奇门 · 12 交运</text>
11
+ <text x="258.5" y="18">0 处不符|128 例 · 5 剔除 · 127 未核 · 312 紫微 · 20 奇门 · 12 交运</text>
12
12
  </g>
13
13
  </svg>
package/engine/almanac.js CHANGED
@@ -10,7 +10,7 @@
10
10
  import { solarToLunar } from './lunar.js';
11
11
  import { nayinOf } from './nayin.js';
12
12
  import {
13
- toJDN, fromJDN, dayGanzhiIndex, ganzhiName, termWindow, termInstantJd, partsToJd, STEMS, BRANCHES, TERM_NAMES,
13
+ toJDN, fromJDN, dayGanzhiIndex, ganzhiName, termWindow, termInstantJd, STEMS, BRANCHES, TERM_NAMES,
14
14
  } from './calendar.js';
15
15
  import { HEX64 } from './interpret-data.js';
16
16
  import { sanheGroupOf } from './sanhe.js';
@@ -196,13 +196,18 @@ export function buildAlmanac(input) {
196
196
  if (day > daysInMonth(year, month)) {
197
197
  throw new Error(`非法日期:${year} 年 ${month} 月没有 ${day} 日`);
198
198
  }
199
- const jdNoon = partsToJd({ year, month, day, hour: 12, minute: 0 });
200
199
  const lunar = solarToLunar(year, month, day);
201
200
  const jdn = toJDN(year, month, day);
202
201
 
202
+ // 黄历是「按日」出版的:交节当天即算新节(通书本子口径),所以节气一律比「日序」,
203
+ // 不比当天正午的时刻。用正午比时刻时,交节落在午后的那半年,整天还挂在上个月——
204
+ // 每年 12 个「节」约一半在午后,等于每年约 6 天的月柱、建除、值日神全错。
205
+ // 八字侧仍按精确到分的交节时刻判月柱(见 bazi.js),两者不共用这一层。
206
+ const termDayIndex = (jd) => Math.floor(jd + 0.5);
207
+
203
208
  // 年柱:立春界;同时给出春节界口径
204
209
  const lichunJd = termInstantJd(year, 2);
205
- const ganzhiYear = jdNoon < lichunJd ? year - 1 : year;
210
+ const ganzhiYear = termDayIndex(lichunJd) <= jdn ? year : year - 1;
206
211
  const yearIdx = (((ganzhiYear - 4) % 60) + 60) % 60;
207
212
  const yearGz = ganzhiName(yearIdx);
208
213
  const springYear = lunar.lunarYear;
@@ -211,9 +216,8 @@ export function buildAlmanac(input) {
211
216
  // 月柱:以「节」为界
212
217
  const jieTerms = termWindow(year, { jieOnly: true });
213
218
  let prevJie = null;
214
- let nextJie = null;
215
219
  for (const t of jieTerms) {
216
- if (t.jd <= jdNoon) prevJie = t; else { if (!nextJie) nextJie = t; }
220
+ if (termDayIndex(t.jd) <= jdn) prevJie = t;
217
221
  }
218
222
  const monthBranchIdx = ((2 + (prevJie.index - 2) / 2) % 12 + 12) % 12;
219
223
  const yinMonthStem = ((STEMS.indexOf(yearGz.stem) % 5) * 2 + 2) % 10;
@@ -231,7 +235,7 @@ export function buildAlmanac(input) {
231
235
  let prevTerm = null;
232
236
  let nextTerm = null;
233
237
  for (const t of allTerms) {
234
- if (t.jd <= jdNoon) prevTerm = t; else { if (!nextTerm) nextTerm = t; }
238
+ if (termDayIndex(t.jd) <= jdn) prevTerm = t; else { if (!nextTerm) nextTerm = t; }
235
239
  }
236
240
  const fmtJdDate = (jd) => {
237
241
  const p = fromJDN(Math.floor(jd + 0.5));
package/engine/bazi.js CHANGED
@@ -8,18 +8,22 @@ import { resolveAddress } from './geo.js';
8
8
  import { computeBaziShensha } from './shensha.js';
9
9
  import { assertCivil, assertShifted, daysInMonth } from './validate.js';
10
10
  import { applyDst } from './dst.js';
11
+ import { bool } from './bool.js';
11
12
  import { nayinOf } from './nayin.js';
12
13
  import { SANHE } from './sanhe.js';
13
14
  import { analyzeBaziFrame, scoreLuck } from './geju.js';
14
15
 
15
16
  // 地支藏干(本气 / 中气 / 余气)
17
+ // 巳的顺序是「丙庚戊」而不是「丙戊庚」:庚长生于巳,是中气;戊是辰月余气,排末位。
18
+ // 本表与下面 RENYUAN_TABLE(巳月:戊 5 日 → 庚 9 日 → 丙 16 日)互为倒序,
19
+ // 也与寅(甲丙戊)、申(庚壬戊)、亥(壬甲)的「长生=中气」写法一致。
16
20
  export const HIDDEN_STEMS = {
17
21
  子: ['癸'],
18
22
  丑: ['己', '癸', '辛'],
19
23
  寅: ['甲', '丙', '戊'],
20
24
  卯: ['乙'],
21
25
  辰: ['戊', '乙', '癸'],
22
- 巳: ['丙', '戊', '庚'],
26
+ 巳: ['丙', '庚', '戊'],
23
27
  午: ['丁', '己'],
24
28
  未: ['己', '丁', '乙'],
25
29
  申: ['庚', '壬', '戊'],
@@ -333,13 +337,13 @@ export function buildBazi(input) {
333
337
  let { year, month, day, hour = 0, minute = 0, gender = 'male' } = input;
334
338
  // 时辰未知:内部仍按「正午」跑完整条管线(大运、旺衰都要有个基准才能算),
335
339
  // 但对外一律不承认这个钟点——时柱置空、文本不出现具体时刻、精度降为按日。
336
- const hourUnknown = input.hourUnknown === true;
340
+ const hourUnknown = bool(input.hourUnknown);
337
341
  if (hourUnknown) { hour = 12; minute = 0; }
338
- const lateZi = input.lateZi !== false;
339
- const useTrueSolar = input.trueSolar !== false;
342
+ const lateZi = bool(input.lateZi, true);
343
+ const useTrueSolar = bool(input.trueSolar, true);
340
344
  // 1986–1991 中国夏令时:出生证钟面时间普遍是「拨快 1 小时」的钟面时间,
341
345
  // 不校正会让时柱(乃至跨 23:00 的日柱)整档偏;显式传 dst:false 可关闭。
342
- const dst = input.dst === false ? { applied: false, note: null } : applyDst({ year, month, day, hour, minute });
346
+ const dst = bool(input.dst, true) ? applyDst({ year, month, day, hour, minute }) : { applied: false, note: null };
343
347
  if (dst.applied) {
344
348
  year = dst.year; month = dst.month; day = dst.day; hour = dst.hour; minute = dst.minute;
345
349
  }
@@ -443,7 +447,7 @@ export function buildBazi(input) {
443
447
 
444
448
  // 流年(默认从「今年」起排 10 年;传 input.nowYear 可钉死基准年,用于复现历史结论;
445
449
  // input.liunianTo 可把窗口延到指定年,生时校正要拿几十年前的事件逐年对账)
446
- const thisYear = Number(input.nowYear) || new Date().getFullYear();
450
+ const thisYear = Number(input.nowYear) || new Date().getFullYear(); // allow-clock: 未传 nowYear 时取本机年份;工具层 asOf 可钉死
447
451
  const liunianTo = Math.max(thisYear, Number(input.liunianTo) || thisYear + 9);
448
452
  const liunian = [];
449
453
  for (let y = thisYear; y <= liunianTo; y++) {
package/engine/bool.js ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * 读参数里的布尔值只走这里。
3
+ *
4
+ * 本文件是引擎入口的「参数口径归一」:布尔(bool / canonicalFlags)与
5
+ * 数字(canonicalNumbers)各一道,盘面读的、句柄编码的、指纹比的是同一份值。
6
+ *
7
+ * 直接写 `args.cross === true` 会把字符串 "true" 与数字 1 都当成 false——
8
+ * 模型经 JSON 传参时这三种写法都出现过,界面层归一后仍可能有旧句柄或直接调用
9
+ * 引擎的路径。defaultValue 决定「没给值」时的口径(如 lateZi 默认 true)。
10
+ */
11
+ export function bool(value, defaultValue = false) {
12
+ if (value === undefined || value === null || value === '') return defaultValue;
13
+ if (value === true || value === 1 || value === 'true' || value === '1') return true;
14
+ if (value === false || value === 0 || value === 'false' || value === '0') return false;
15
+ return defaultValue;
16
+ }
17
+
18
+ /**
19
+ * 引擎入口的开关归一表:键 → 未给值时的默认口径。
20
+ * 只收 schema 里声明为 boolean 的参数;盘面读值与句柄编码必须看到同一份值。
21
+ */
22
+ export const FLAG_DEFAULTS = {
23
+ lateZi: true, trueSolar: true, dst: true,
24
+ isLeapMonth: false, leapAsNext: false,
25
+ cross: false, hourUnknown: false, diff: false, shadow: false,
26
+ trace: false, rectify: false, withInterpret: false,
27
+ };
28
+
29
+ /** 合盘对方 / 第三方对象里的同名开关(见 hepan、shadowPerson):键 → 未给值时的默认口径。
30
+ * hourUnknown 的缺省是 false(合盘遇到 true 会拦),不能跟着 lateZi 等默认开。 */
31
+ const PARTNER_FLAGS = {
32
+ lateZi: true,
33
+ trueSolar: true,
34
+ dst: true,
35
+ hourUnknown: false,
36
+ };
37
+
38
+ function canonicalInto(source, defaults) {
39
+ let out = source;
40
+ const put = (key, value) => {
41
+ if (Object.is(source[key], value)) return;
42
+ if (out === source) out = { ...source };
43
+ out[key] = value;
44
+ };
45
+ const drop = (key) => {
46
+ if (out === source) out = { ...source };
47
+ delete out[key];
48
+ };
49
+ for (const [key, fallback] of Object.entries(defaults)) {
50
+ // 没给这个键就一个键都不加:凭空补 true 会改掉所有旧句柄的身份
51
+ if (source[key] === undefined) continue;
52
+ // 空串 / 空白串按「没给」处理(宿主 normalizeArgs 也是这么收的):
53
+ // 各体系的缺省并不相同——奇门 lateZi 缺省 same-day、紫微 lateZishi 缺省 current-day,
54
+ // 若把 "" 折成全局缺省 true,同一次输入走工具层和直调引擎会排出两张盘。
55
+ if (typeof source[key] === 'string' && source[key].trim() === '') { drop(key); continue; }
56
+ put(key, bool(source[key], fallback));
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function canonicalPartner(person) {
62
+ if (!person || typeof person !== 'object' || Array.isArray(person)) return person;
63
+ let out = person;
64
+ for (const [key, fallback] of Object.entries(PARTNER_FLAGS)) {
65
+ if (person[key] === undefined) continue;
66
+ if (typeof person[key] === 'string' && person[key].trim() === '') {
67
+ if (out === person) out = { ...person };
68
+ delete out[key];
69
+ continue;
70
+ }
71
+ const v = bool(person[key], fallback);
72
+ if (Object.is(person[key], v)) continue;
73
+ if (out === person) out = { ...person };
74
+ out[key] = v;
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * 把 args 里的字符串布尔 / 数字布尔统一成真布尔,返回**新对象**(无改动时返回原对象)。
81
+ *
82
+ * 为什么要在入口做一次:0.62.x 的盘面读开关用 `!== false`(只认字面 false),
83
+ * 句柄却直接编码传进来的原文。于是 `trueSolar:"false"` 排出「开真太阳时」的盘,
84
+ * 句柄里却写着 false——同一张盘两个身份,影子盘对拍、深链回放、跨版本 diff 全跟着错。
85
+ * 现在读值已统一走 `bool()`,这道归一仍要在入口做:它保证句柄与指纹拿到的
86
+ * 也是同一份真布尔,而不只是「读的时候碰巧算出同一个结果」。
87
+ */
88
+ export function canonicalFlags(args) {
89
+ if (!args || typeof args !== 'object') return args;
90
+ let out = canonicalInto(args, FLAG_DEFAULTS);
91
+ for (const key of ['partner', 'partner2']) {
92
+ const person = out[key];
93
+ const fixed = canonicalPartner(person);
94
+ if (fixed !== person) {
95
+ if (out === args) out = { ...args };
96
+ out[key] = fixed;
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ // ── 数字归一 ────────────────────────────────────────────────
103
+ // 只把「整串就是一个数」的字符串转成数字:count:"3" → 3、period:"9" → 9。
104
+ // "9运" / "十" / 空串原样留着,让各体系原有的范围校验报 E_INPUT,
105
+ // 而不是先变成 NaN 再以「没给」的身份混过去。
106
+
107
+ /** schema 里 type 为 number / integer 的顶层键(options 由工具层摊平到顶层) */
108
+ export const NUMERIC_KEYS = [
109
+ 'hour', 'minute', 'longitude', 'verifyPast', 'seed',
110
+ 'days', 'lunarYear', 'lunarMonth', 'lunarDay',
111
+ 'num1', 'num2', 'num3', 'upper', 'lower', 'changing',
112
+ 'count', 'toAge', 'fortuneYear', 'period', 'limit',
113
+ 'sittingOffset', 'sittingDegree', 'facingOffset', 'facingDegree',
114
+ 'year', 'month', 'day',
115
+ ];
116
+
117
+ /** 数值数组:逐项转,任一项不是数字就整组不动,交给原有校验报错 */
118
+ export const NUMERIC_ARRAY_KEYS = ['lines', 'rectifyHours', 'answers'];
119
+
120
+ /** 字形 → 笔画的映射:值也是数字(起名的 strokeOverrides / surnameStrokes) */
121
+ export const NUMERIC_MAP_KEYS = ['strokeOverrides', 'surnameStrokes'];
122
+
123
+ // 与宿主 normalizeArgs 同一把尺:指数写法(1e3)与尾点(5.)都算「整串是一个数」。
124
+ // 收窄成十进制会把 seed:"1e3" 留在字符串态——randomOf 照样按 1000 出牌,句柄却记着
125
+ // "1e3",同一张盘两个身份,正是这刀要消掉的东西。
126
+ const NUMERIC_RE = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
127
+
128
+ function numericOf(value) {
129
+ if (typeof value !== 'string') return value;
130
+ const s = value.trim();
131
+ if (!NUMERIC_RE.test(s)) return value;
132
+ const n = Number(s);
133
+ return Number.isFinite(n) ? n : value;
134
+ }
135
+
136
+ function canonicalMap(obj) {
137
+ let changed = false;
138
+ const next = {};
139
+ for (const [k, v] of Object.entries(obj)) {
140
+ const n = v === null ? null : numericOf(v);
141
+ if (!Object.is(n, v)) changed = true;
142
+ next[k] = n;
143
+ }
144
+ return changed ? next : obj;
145
+ }
146
+
147
+ /**
148
+ * 把数字串统一成数字,返回**新对象**(无改动时返回原对象)。
149
+ *
150
+ * 为什么也要在入口做一刀:句柄直接编码参数原文,`count:"3"` 与 `count:3`
151
+ * 抽出同一副牌、却拿到两个句柄——影子盘对拍、深链回放会认成两张盘。
152
+ * 另有两处不只影响身份,是实打实的算错:
153
+ * * 风水 `period:"9"` 被 1–9 校验拒掉,报错里还写着「收到「9」」,看着像误报;
154
+ * * 起名 `strokeOverrides:{"秋":"9"}` 被 overridesOf 静默丢掉,五格退回字库笔画,
155
+ * 用户给的笔画根本没参与计算。
156
+ */
157
+ export function canonicalNumbers(args) {
158
+ if (!args || typeof args !== 'object') return args;
159
+ let out = args;
160
+ const put = (key, value) => {
161
+ if (Object.is(args[key], value)) return;
162
+ if (out === args) out = { ...args };
163
+ out[key] = value;
164
+ };
165
+ for (const key of NUMERIC_KEYS) {
166
+ if (args[key] === undefined) continue;
167
+ // 空串 / 空白串按「没给」处理(与宿主 normalizeArgs 一致)。留着会变成另一条路:
168
+ // 梅花数字起卦 Number("") = 0、风水 period 直接报「收到「」」——同一份输入两种结果。
169
+ if (typeof args[key] === 'string' && args[key].trim() === '') {
170
+ if (out === args) out = { ...args };
171
+ delete out[key];
172
+ continue;
173
+ }
174
+ put(key, numericOf(args[key]));
175
+ }
176
+ for (const key of NUMERIC_ARRAY_KEYS) {
177
+ const v = args[key];
178
+ if (!Array.isArray(v)) continue;
179
+ let changed = false;
180
+ const next = v.map((x) => {
181
+ if (x === null) return null;
182
+ if (typeof x === 'string' && x.trim() === '') { changed = true; return undefined; }
183
+ const n = numericOf(x);
184
+ if (!Object.is(n, x)) changed = true;
185
+ return n;
186
+ });
187
+ if (changed) put(key, next);
188
+ }
189
+ for (const key of NUMERIC_MAP_KEYS) {
190
+ const v = args[key];
191
+ if (!v || typeof v !== 'object' || Array.isArray(v)) continue;
192
+ put(key, canonicalMap(v));
193
+ }
194
+ return out;
195
+ }
@@ -1,5 +1,6 @@
1
1
  import { earthL } from './vsop-earth.js';
2
2
  import { createLru } from './lru.js';
3
+ import { bool } from './bool.js';
3
4
  // 历法底座:儒略日、干支、二十四节气、真太阳时
4
5
  // 全部为纯函数,不依赖 DOM / Node API,Host 与 Client 可共用。
5
6
 
@@ -146,7 +147,14 @@ export function deltaTSeconds(year) {
146
147
  return v0 + ((v1 - v0) * (year - y0)) / (y1 - y0);
147
148
  }
148
149
  }
149
- return knots[knots.length - 1][1];
150
+ // 2100 年后用 Morrison & Stephenson (2004) 抛物线外推:ΔT ≈ 32 × ((y − 1820)/100)² 秒。
151
+ // 注意单位:括号里是「世纪」,不是年——写成 32×(y−1820)² 会在 2400 年给出 1076 万秒(约 124 天),
152
+ // 把节气、朔望整体推后好几个月。这里以 2100 端点(110s)接续抛物线增长率,保证 2100→2101 连续:
153
+ // 2101 约 111.8s、2200 约 321s、2400 约 936s。
154
+ const [yLast, vLast] = knots[knots.length - 1];
155
+ const uLast = (yLast - 1820) / 100;
156
+ const u = (year - 1820) / 100;
157
+ return vLast + 32 * (u * u - uLast * uLast);
150
158
  }
151
159
 
152
160
  // 1929 年元旦之前,中国历书按「北京地方平时」(116.4°E,UTC+7.7611)推算朔与中气;
@@ -289,7 +297,7 @@ function termYearList(year, jieOnly) {
289
297
  * @param {{jieOnly?: boolean}} [opts] jieOnly=true 时只返回十二节(default:false,含气)
290
298
  */
291
299
  export function termWindow(year, opts = {}) {
292
- const jieOnly = opts.jieOnly === true;
300
+ const jieOnly = bool(opts.jieOnly);
293
301
  const cacheKey = jieOnly ? `${year}|jie` : year;
294
302
  const cached = TERM_WINDOW_CACHE.get(cacheKey);
295
303
  if (cached) return cached;
package/engine/chenggu.js CHANGED
@@ -5,6 +5,7 @@ import { ganzhiName, toJDN, fromJDN, dayGanzhiIndex } from './calendar.js';
5
5
  import { assertCivil, assertShifted } from './validate.js';
6
6
  import { hourToBranch } from './almanac.js';
7
7
  import { fail } from './fail.js';
8
+ import { bool } from './bool.js';
8
9
 
9
10
  /** 六十甲子年骨重(单位:钱;索引 = (农历年 - 4) mod 60,0 = 甲子)。 */
10
11
  export const YEAR_WEIGHT = [
@@ -147,7 +148,7 @@ export function buildChenggu(input) {
147
148
  }
148
149
  // 晚子时换日与八字/六壬/奇门同口径:23:00–23:59 按次日取农历日与日柱,
149
150
  // 否则同一子时的前后两个钟点会差一天的日重
150
- const lateZi = input.lateZi !== false;
151
+ const lateZi = bool(input.lateZi, true);
151
152
  let sy = year;
152
153
  let sm = month;
153
154
  let sd = day;
package/engine/convert.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import { solarToLunar } from './lunar.js';
11
11
  import { fromJDN, toJDN } from './calendar.js';
12
12
  import { buildAlmanac } from './almanac.js';
13
+ import { bool } from './bool.js';
13
14
  import { assertCivil, assertObject, daysInMonth } from './validate.js';
14
15
  import { fail } from './fail.js';
15
16
 
@@ -91,7 +92,7 @@ export function buildConvert(input) {
91
92
  if (m < 1 || m > 12) throw fail('E_INPUT', '农历月份须在 1–12');
92
93
  const months = lunarMonths(y);
93
94
  if (!months.length) throw fail('E_INPUT', `农历 ${y} 年无法定位正月,请核对年份(1600–2400)`);
94
- const isLeap = input.isLeapMonth === true;
95
+ const isLeap = bool(input.isLeapMonth);
95
96
  const hit = months.find((x) => x.month === m && x.isLeap === isLeap);
96
97
  if (!hit) {
97
98
  const leapMonths = months.filter((x) => x.isLeap).map((x) => `闰${MONTH_TEXT[x.month - 1]}月`);
@@ -4,6 +4,7 @@
4
4
  import { toJDN, dayGanzhiIndex, ganzhiName, termWindow, formatJd, jdToParts, xunKongOf } from './calendar.js';
5
5
  import { assertCivil, assertShifted } from './validate.js';
6
6
  import { SANHE_ELEMENT } from './sanhe.js';
7
+ import { bool } from './bool.js';
7
8
 
8
9
  const GAN = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'];
9
10
  const ZHI = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'];
@@ -308,7 +309,7 @@ function fanYin(ke, dayGan, dayZhi, stats, isFB) {
308
309
  export function buildDaliuren(input) {
309
310
  assertCivil(input);
310
311
  const { year, month, day, hour = 0, minute = 0 } = input;
311
- const lateZi = input.lateZi !== false;
312
+ const lateZi = bool(input.lateZi, true);
312
313
  const jdnBase = toJDN(year, month, day);
313
314
  const jdn = lateZi && hour >= 23 ? jdnBase + 1 : jdnBase;
314
315
  const dayIdx = dayGanzhiIndex(jdn);
package/engine/diff.js CHANGED
@@ -6,6 +6,8 @@
6
6
  //
7
7
  // 只做结构化比对,不改动任何引擎的输出。
8
8
 
9
+ import { bool } from './bool.js';
10
+
9
11
  /** 比对时忽略的字段:文案、时间戳、口径回显、内部儒略日数字 */
10
12
  const IGNORE_KEYS = new Set([
11
13
  'summary', 'interpret', 'castAt', 'asOf', 'precision', 'disclaimer', 'note',
@@ -276,7 +278,7 @@ export const CALIBER_SWITCHES = {
276
278
  key: 'lateZi',
277
279
  title: '晚子时:23:00 后是否换日',
278
280
  short: '晚子时',
279
- read: (a) => a.lateZi !== false,
281
+ read: (a) => bool(a.lateZi, true),
280
282
  alt: (v) => !v,
281
283
  label: (v) => (v ? '换日' : '不换日'),
282
284
  },
@@ -284,7 +286,7 @@ export const CALIBER_SWITCHES = {
284
286
  key: 'trueSolar',
285
287
  title: '真太阳时:是否按出生地经度修正',
286
288
  short: '真太阳时',
287
- read: (a) => a.trueSolar !== false,
289
+ read: (a) => bool(a.trueSolar, true),
288
290
  alt: (v) => !v,
289
291
  label: (v) => (v ? '修正' : '不修正'),
290
292
  },
@@ -302,7 +304,7 @@ export const CALIBER_SWITCHES = {
302
304
  key: 'trueSolar',
303
305
  title: '真太阳时:是否按出生地经度修正',
304
306
  short: '真太阳时',
305
- read: (a) => a.trueSolar !== false,
307
+ read: (a) => bool(a.trueSolar, true),
306
308
  alt: (v) => !v,
307
309
  label: (v) => (v ? '修正' : '不修正'),
308
310
  },
@@ -6,6 +6,8 @@ import {
6
6
  } from './calendar.js';
7
7
  import { applyDst } from './dst.js';
8
8
  import { fail } from './fail.js';
9
+ import { bool } from './bool.js';
10
+ import { assertCivil, MIN_YEAR, MAX_YEAR } from './validate.js';
9
11
 
10
12
  /** 九星名称表:数 → {name, color, element, star(北斗名), lucky}。 */
11
13
  export const NINE_STARS = {
@@ -94,7 +96,10 @@ const GUA_BY_NUM = ['坎', '坤', '震', '巽', '中', '乾', '兑', '艮', '离
94
96
  */
95
97
  export function mingGua(birthYear, gender) {
96
98
  const tail = ((birthYear % 100) + 100) % 100;
97
- const century = birthYear >= 2000 ? { male: 99, female: 3 } : { male: 100, female: 4 };
99
+ // 世纪常数每个世纪减 1:1900s 男 100 / 女 4,2000s 99 / 3,2100s 98 / 2……
100
+ // 以前只在「>=2000」时切一次 99/3,2100 年以后(引擎支持到 2400)会整档算错。
101
+ const shift = Math.floor(birthYear / 100) - 19;
102
+ const century = { male: 100 - shift, female: 4 - shift };
98
103
  const mod9 = (n) => { const r = ((n % 9) + 9) % 9; return r === 0 ? 9 : r; };
99
104
  let num = gender === 'male' ? mod9(century.male - tail) : mod9(tail - century.female);
100
105
  if (num === 5) num = gender === 'male' ? 2 : 8;
@@ -114,10 +119,11 @@ export function eightStars(gua) {
114
119
  return result;
115
120
  }
116
121
 
117
- /** 立春界节气年:生日在当年立春前 → 上一年(按正午判,与黄历一致)。 */
122
+ /** 立春界节气年:生日在当年立春前 → 上一年。
123
+ * 按「日」判(立春当日即算新年),与黄历同一口径;本函数只收年月日,不看当天交节时刻——
124
+ * 命卦本来就只按出生年取,同一天出生的人不因生在 16:27 前后换卦。 */
118
125
  export function guaYearOf(year, month, day) {
119
- const birthJd = partsToJd({ year, month, day, hour: 12, minute: 0 });
120
- return birthJd < termInstantJd(year, 2) ? year - 1 : year;
126
+ return Math.floor(termInstantJd(year, 2) + 0.5) <= toJDN(year, month, day) ? year : year - 1;
121
127
  }
122
128
 
123
129
  /**
@@ -214,13 +220,20 @@ export function timeFlyingOf(at) {
214
220
  const hour = Number.isInteger(at.hour) ? at.hour : 12;
215
221
  const minute = Number.isInteger(at.minute) ? at.minute : 0;
216
222
  const jd = partsToJd({ year, month, day, hour, minute });
223
+ // 交节的判定基准:给了时刻就按精确时刻(14:30 出生、当天 17:03 才立春,仍在上一月);
224
+ // 只给日期则按「日」——当天交节即算新节。旧实现一律用正午,交节在午后的那半天会算成上个月,
225
+ // 与同一页黄历的月柱(已按日判)自相矛盾。
226
+ const hasHour = Number.isInteger(at.hour);
227
+ const jdTerm = hasHour ? jd : toJDN(year, month, day) + 0.5 - 1e-6;
217
228
  const terms = termWindow(year);
218
- const guaYear = guaYearOf(year, month, day);
229
+ // 年紫白同样以立春为界,且与上面月/日/时用同一个基准:给时刻按时刻,只给日期按日。
230
+ // 若这里改回只看日期的 guaYearOf,立春当天的「年」会是新年、「月」还是上一月,同盘自相矛盾。
231
+ const guaYear = jdTerm >= termInstantJd(year, 2) ? year : year - 1;
219
232
  const yearBranch = yearBranchOf(guaYear);
220
233
  const yearGroup = branchGroupOf(yearBranch);
221
- const prevJie = previousJie(terms, jd);
234
+ const prevJie = previousJie(terms, jdTerm);
222
235
  const monthNum = JIE_MONTH_NUM[prevJie ? prevJie.name : '立春'] || 1;
223
- const half = zibaiHalfAt(terms, jd);
236
+ const half = zibaiHalfAt(terms, jdTerm);
224
237
  const jdn = toJDN(year, month, day) + (hour >= 23 ? 1 : 0);
225
238
  const dayIdx = dayGanzhiIndex(jdn);
226
239
  const dayName = ganzhiName(dayIdx).name;
@@ -440,9 +453,10 @@ export function xuankongChart(period, sitting, facing, opts = {}) {
440
453
 
441
454
  /** DST 校正出生日期(1986–1991),返回校正后的年月日。 */
442
455
  function civilDate(input) {
443
- const dst = input.dst === false
444
- ? { year: input.year, month: input.month, day: input.day, hour: 0, minute: 0, applied: false, note: null }
445
- : applyDst({ year: input.year, month: input.month, day: input.day, hour: 0, minute: 0 });
456
+ // 开启 DST 校正(默认开)才走 applyDst;显式关闭时不动日期
457
+ const dst = bool(input.dst, true)
458
+ ? applyDst({ year: input.year, month: input.month, day: input.day, hour: 0, minute: 0 })
459
+ : { year: input.year, month: input.month, day: input.day, hour: 0, minute: 0, applied: false, note: null };
446
460
  return { year: dst.year, month: dst.month, day: dst.day, dst };
447
461
  }
448
462
 
@@ -471,6 +485,10 @@ export function buildFengshui(input) {
471
485
  if (input.fortuneYear !== undefined && input.fortuneYear !== null && input.fortuneYear !== '') {
472
486
  const fy = Number(input.fortuneYear);
473
487
  if (!Number.isInteger(fy)) throw fail('E_INPUT', `fortuneYear 必须是整数年份,收到「${input.fortuneYear}」`);
488
+ // 只校验「是整数」会放过 0 / 负数:年紫白照样按公式给出一颗星,看着像算过。
489
+ if (fy < MIN_YEAR || fy > MAX_YEAR) {
490
+ throw fail('E_INPUT', `fortuneYear 超出支持范围 ${MIN_YEAR}–${MAX_YEAR}(收到 ${fy})`);
491
+ }
474
492
  input = { ...input, fortuneYear: fy };
475
493
  }
476
494
  const action = input.action;
@@ -486,6 +504,9 @@ export function buildFengshui(input) {
486
504
  if (!Number.isInteger(input.year) || !Number.isInteger(input.month) || !Number.isInteger(input.day)) {
487
505
  throw fail('E_INPUT', '风水命卦需要完整出生年月日(公历)——只知道年份不够:命卦按立春界划分出生年');
488
506
  }
507
+ // 与其它体系同一把尺:2 月 30 日、13 月、越界年份必须在入口报错,
508
+ // 否则会被 Date 静默滚到下个月,命卦按另一天算出来却没人知道。
509
+ assertCivil(input, '风水命卦');
489
510
  const civil = civilDate(input);
490
511
  const guaYear = guaYearOf(civil.year, civil.month, civil.day);
491
512
  const { num, gua, direction } = mingGua(guaYear, gender);
@@ -648,7 +669,7 @@ function parseFortuneAt(input) {
648
669
  }
649
670
  const [hour, minute] = rawTime.split(':').map(Number);
650
671
  if (hour > 23 || minute > 59) throw fail('E_INPUT', `fortuneTime 超出范围(HH:MM):「${rawTime}」`);
651
- const dst = input.dst === false ? null : applyDst({ year, month, day, hour, minute });
672
+ const dst = bool(input.dst, true) ? applyDst({ year, month, day, hour, minute }) : null;
652
673
  const at = dst ? { year: dst.year, month: dst.month, day: dst.day, hour: dst.hour, minute: dst.minute } : { year, month, day, hour, minute };
653
674
  return { at, dst };
654
675
  }
@@ -706,7 +727,7 @@ function buildXuankong(input) {
706
727
  action: 'xuankong',
707
728
  period,
708
729
  periodLabel: yearInfo ? yearInfo.label : `${period}运`,
709
- ...(yearInfo ? { year: input.fortuneYear || (fortune && fortune.at.year), periodStart: yearInfo.start, periodEnd: yearInfo.end } : {}),
730
+ ...(yearInfo ? { year: input.fortuneYear ?? (fortune && fortune.at.year), periodStart: yearInfo.start, periodEnd: yearInfo.end } : {}),
710
731
  sitting: `${sitting.m}山(罗盘 ${sitting.deg}°)`,
711
732
  facing: `${facing.m}山(罗盘 ${facing.deg}°)`,
712
733
  sittingGua: sitting.gua,
package/engine/geju.js CHANGED
@@ -26,7 +26,7 @@ const GENERATES = { 木: '火', 火: '土', 土: '金', 金: '水', 水: '木' }
26
26
  const OVERCOMES = { 木: '土', 土: '水', 水: '火', 火: '金', 金: '木' };
27
27
  const HIDDEN = {
28
28
  子: ['癸'], 丑: ['己', '癸', '辛'], 寅: ['甲', '丙', '戊'], 卯: ['乙'],
29
- 辰: ['戊', '乙', '癸'], 巳: ['丙', '戊', '庚'], 午: ['丁', '己'], 未: ['己', '丁', '乙'],
29
+ 辰: ['戊', '乙', '癸'], 巳: ['丙', '庚', '戊'], 午: ['丁', '己'], 未: ['己', '丁', '乙'],
30
30
  申: ['庚', '壬', '戊'], 酉: ['辛'], 戌: ['戊', '辛', '丁'], 亥: ['壬', '甲'],
31
31
  };
32
32
  const HIDDEN_W = [1, 0.5, 0.3];
@@ -117,10 +117,23 @@ function monthPattern(pillars, dayStem, monthBranch) {
117
117
  w: HIDDEN_W[i] ?? 0.1,
118
118
  }));
119
119
  const others = ['year', 'month', 'hour'];
120
- const exposedKey = others.find((k) => cands.some((c) => c.stem === pillars[k].stem));
121
120
  const base = cands[0];
121
+ // 取格按「藏干位阶」定先后:本气透则取本气,本气不透再取中气、余气(子平通例)。
122
+ // 不能按柱位「先遇上谁就用谁」——那样月干透出的中气会输给年干透出的余气,
123
+ // 实测约 13% 的盘有两处以上不同藏干并透,其中七成以上会因此取错格局名。
124
+ // 同一藏干多柱并透时,「月」优先报出(离月令最近)。
125
+ let exposedKey = null;
126
+ let use = null;
127
+ for (const c of cands) {
128
+ const hits = others.filter((k) => pillars[k].stem === c.stem);
129
+ if (hits.length) {
130
+ use = c;
131
+ exposedKey = hits.includes('month') ? 'month' : hits[0];
132
+ break;
133
+ }
134
+ }
122
135
  const hui = exposedKey ? null : huiCandidate(pillars, monthBranch, cands);
123
- const use = exposedKey ? cands.find((c) => c.stem === pillars[exposedKey].stem) : (hui || base);
136
+ if (!use) use = hui || base;
124
137
  const usedBy = exposedKey ? '透干' : (hui ? '会支' : '本气');
125
138
  const exposedPos = exposedKey ? { year: '年', month: '月', hour: '时' }[exposedKey] : null;
126
139
 
package/engine/geo.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // 坐标为近似值(±0.2°≈0.8 分钟时差),足够真太阳时使用。
3
3
  // 联网(Nominatim)是显式可选项:只有调用方传 online: true 才会把地址原文发出去,
4
4
  // 默认走内置表,不上传任何数据。
5
+ import { bool } from './bool.js';
5
6
  const RAW = `
6
7
  北京 116.41 39.90;上海 121.47 31.23;天津 117.20 39.13;重庆 106.55 29.56
7
8
  广州 113.26 23.13;深圳 114.06 22.55;珠海 113.58 22.27;东莞 113.75 23.02;佛山 113.12 23.02
@@ -229,7 +230,7 @@ export async function geocodeAddress(input, opts = {}) {
229
230
  const text = String(input || '').trim();
230
231
  if (!text) return local;
231
232
  // 默认离线:联网必须显式开启(会把出生地原文发给第三方)
232
- const wantOnline = opts.online === true;
233
+ const wantOnline = bool(opts.online);
233
234
  if (!wantOnline) return local;
234
235
  if (local && typeof local.lon === 'number' && !DETAIL.test(text)) return local;
235
236
  const remote = await onlineLookup(text, opts);
package/engine/hepan.js CHANGED
@@ -5,6 +5,7 @@ import { buildBazi, tenGod } from './bazi.js';
5
5
  import { STEMS, BRANCHES } from './calendar.js';
6
6
  import { assertCivil } from './validate.js';
7
7
  import { SANHE_GROUPS } from './sanhe.js';
8
+ import { bool } from './bool.js';
8
9
 
9
10
  const WX_GAN = { 甲: '木', 乙: '木', 丙: '火', 丁: '火', 戊: '土', 己: '土', 庚: '金', 辛: '金', 壬: '水', 癸: '水' };
10
11
  const WX_ZHI = { 子: '水', 丑: '土', 寅: '木', 卯: '木', 辰: '土', 巳: '火', 午: '火', 未: '土', 申: '金', 酉: '金', 戌: '土', 亥: '水' };
@@ -217,7 +218,7 @@ export function buildHepan(input) {
217
218
  tenGodToA: godB,
218
219
  text: he5
219
220
  ? `日干五合(${aStem}${bStem}),传统称「天干五合」,主彼此吸引强,也容易黏在一起分不开。`
220
- : `日干无五合:你在对方盘里是「${godA}」,对方在你盘里是「${godB}」。`,
221
+ : `日干无五合:对方在你盘里是「${godA}」,你在对方盘里是「${godB}」。`,
221
222
  },
222
223
  crossBranches: uniqCross.slice(0, 8),
223
224
  nayin: { a: nyA, b: nyB, label: nayinRel.name, text: nayinRel.text },
@@ -239,7 +240,7 @@ export function buildHepan(input) {
239
240
  wuxing: wxA,
240
241
  // 真太阳时口径要能被看到:合盘与单盘用同一套修正,否则同一个人会出现两套四柱
241
242
  longitude: A.input.longitude ?? null,
242
- trueSolar: A.input.trueSolar !== false,
243
+ trueSolar: bool(A.input.trueSolar, true),
243
244
  solarNote: A.solarTime ? A.solarTime.note : '',
244
245
  summary: A.summary,
245
246
  },
@@ -252,7 +253,7 @@ export function buildHepan(input) {
252
253
  favor: favorB.join(''),
253
254
  wuxing: wxB,
254
255
  longitude: B.input.longitude ?? null,
255
- trueSolar: B.input.trueSolar !== false,
256
+ trueSolar: bool(B.input.trueSolar, true),
256
257
  solarNote: B.solarTime ? B.solarTime.note : '',
257
258
  summary: B.summary,
258
259
  },