dsh-metaphysics 0.62.1 → 0.62.2

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 (45) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/client.almanac.js +69 -65
  3. package/client.bazi.js +123 -119
  4. package/client.chenggu.js +68 -64
  5. package/client.convert.js +71 -67
  6. package/client.daliuren.js +119 -115
  7. package/client.fengshui.js +68 -64
  8. package/client.hepan.js +123 -119
  9. package/client.js +192 -192
  10. package/client.liuyao.js +122 -118
  11. package/client.mbti.js +69 -65
  12. package/client.name.js +69 -65
  13. package/client.numerology.js +68 -64
  14. package/client.qimen.js +124 -120
  15. package/client.star.js +69 -65
  16. package/client.tarot.js +119 -115
  17. package/client.trend.js +113 -109
  18. package/client.xiaoliuren.js +78 -74
  19. package/client.zeri.js +68 -64
  20. package/client.ziwei.js +123 -119
  21. package/engine/bazi.js +6 -5
  22. package/engine/bool.js +195 -0
  23. package/engine/calendar.js +2 -1
  24. package/engine/chenggu.js +2 -1
  25. package/engine/convert.js +2 -1
  26. package/engine/daliuren.js +2 -1
  27. package/engine/diff.js +5 -3
  28. package/engine/fengshui.js +19 -6
  29. package/engine/geo.js +2 -1
  30. package/engine/hepan.js +4 -3
  31. package/engine/index.js +202 -72
  32. package/engine/liuyao.js +24 -5
  33. package/engine/mbti.js +6 -1
  34. package/engine/meihua.js +21 -7
  35. package/engine/name.js +20 -12
  36. package/engine/random.js +17 -3
  37. package/engine/rectify.js +4 -3
  38. package/engine/tarot.js +22 -8
  39. package/engine/trace.js +2 -1
  40. package/engine/trend.js +7 -3
  41. package/engine/xiaoliuren.js +2 -1
  42. package/engine/ziwei.js +4 -3
  43. package/index.js +367 -42
  44. package/package.json +1 -1
  45. package//345/221/275/347/220/206/346/216/222/347/233/230.html +577 -143
package/engine/bazi.js CHANGED
@@ -8,6 +8,7 @@ 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';
@@ -333,13 +334,13 @@ export function buildBazi(input) {
333
334
  let { year, month, day, hour = 0, minute = 0, gender = 'male' } = input;
334
335
  // 时辰未知:内部仍按「正午」跑完整条管线(大运、旺衰都要有个基准才能算),
335
336
  // 但对外一律不承认这个钟点——时柱置空、文本不出现具体时刻、精度降为按日。
336
- const hourUnknown = input.hourUnknown === true;
337
+ const hourUnknown = bool(input.hourUnknown);
337
338
  if (hourUnknown) { hour = 12; minute = 0; }
338
- const lateZi = input.lateZi !== false;
339
- const useTrueSolar = input.trueSolar !== false;
339
+ const lateZi = bool(input.lateZi, true);
340
+ const useTrueSolar = bool(input.trueSolar, true);
340
341
  // 1986–1991 中国夏令时:出生证钟面时间普遍是「拨快 1 小时」的钟面时间,
341
342
  // 不校正会让时柱(乃至跨 23:00 的日柱)整档偏;显式传 dst:false 可关闭。
342
- const dst = input.dst === false ? { applied: false, note: null } : applyDst({ year, month, day, hour, minute });
343
+ const dst = bool(input.dst, true) ? applyDst({ year, month, day, hour, minute }) : { applied: false, note: null };
343
344
  if (dst.applied) {
344
345
  year = dst.year; month = dst.month; day = dst.day; hour = dst.hour; minute = dst.minute;
345
346
  }
@@ -443,7 +444,7 @@ export function buildBazi(input) {
443
444
 
444
445
  // 流年(默认从「今年」起排 10 年;传 input.nowYear 可钉死基准年,用于复现历史结论;
445
446
  // input.liunianTo 可把窗口延到指定年,生时校正要拿几十年前的事件逐年对账)
446
- const thisYear = Number(input.nowYear) || new Date().getFullYear();
447
+ const thisYear = Number(input.nowYear) || new Date().getFullYear(); // allow-clock: 未传 nowYear 时取本机年份;工具层 asOf 可钉死
447
448
  const liunianTo = Math.max(thisYear, Number(input.liunianTo) || thisYear + 9);
448
449
  const liunian = [];
449
450
  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
 
@@ -289,7 +290,7 @@ function termYearList(year, jieOnly) {
289
290
  * @param {{jieOnly?: boolean}} [opts] jieOnly=true 时只返回十二节(default:false,含气)
290
291
  */
291
292
  export function termWindow(year, opts = {}) {
292
- const jieOnly = opts.jieOnly === true;
293
+ const jieOnly = bool(opts.jieOnly);
293
294
  const cacheKey = jieOnly ? `${year}|jie` : year;
294
295
  const cached = TERM_WINDOW_CACHE.get(cacheKey);
295
296
  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;
@@ -440,9 +445,10 @@ export function xuankongChart(period, sitting, facing, opts = {}) {
440
445
 
441
446
  /** DST 校正出生日期(1986–1991),返回校正后的年月日。 */
442
447
  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 });
448
+ // 开启 DST 校正(默认开)才走 applyDst;显式关闭时不动日期
449
+ const dst = bool(input.dst, true)
450
+ ? applyDst({ year: input.year, month: input.month, day: input.day, hour: 0, minute: 0 })
451
+ : { year: input.year, month: input.month, day: input.day, hour: 0, minute: 0, applied: false, note: null };
446
452
  return { year: dst.year, month: dst.month, day: dst.day, dst };
447
453
  }
448
454
 
@@ -471,6 +477,10 @@ export function buildFengshui(input) {
471
477
  if (input.fortuneYear !== undefined && input.fortuneYear !== null && input.fortuneYear !== '') {
472
478
  const fy = Number(input.fortuneYear);
473
479
  if (!Number.isInteger(fy)) throw fail('E_INPUT', `fortuneYear 必须是整数年份,收到「${input.fortuneYear}」`);
480
+ // 只校验「是整数」会放过 0 / 负数:年紫白照样按公式给出一颗星,看着像算过。
481
+ if (fy < MIN_YEAR || fy > MAX_YEAR) {
482
+ throw fail('E_INPUT', `fortuneYear 超出支持范围 ${MIN_YEAR}–${MAX_YEAR}(收到 ${fy})`);
483
+ }
474
484
  input = { ...input, fortuneYear: fy };
475
485
  }
476
486
  const action = input.action;
@@ -486,6 +496,9 @@ export function buildFengshui(input) {
486
496
  if (!Number.isInteger(input.year) || !Number.isInteger(input.month) || !Number.isInteger(input.day)) {
487
497
  throw fail('E_INPUT', '风水命卦需要完整出生年月日(公历)——只知道年份不够:命卦按立春界划分出生年');
488
498
  }
499
+ // 与其它体系同一把尺:2 月 30 日、13 月、越界年份必须在入口报错,
500
+ // 否则会被 Date 静默滚到下个月,命卦按另一天算出来却没人知道。
501
+ assertCivil(input, '风水命卦');
489
502
  const civil = civilDate(input);
490
503
  const guaYear = guaYearOf(civil.year, civil.month, civil.day);
491
504
  const { num, gua, direction } = mingGua(guaYear, gender);
@@ -648,7 +661,7 @@ function parseFortuneAt(input) {
648
661
  }
649
662
  const [hour, minute] = rawTime.split(':').map(Number);
650
663
  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 });
664
+ const dst = bool(input.dst, true) ? applyDst({ year, month, day, hour, minute }) : null;
652
665
  const at = dst ? { year: dst.year, month: dst.month, day: dst.day, hour: dst.hour, minute: dst.minute } : { year, month, day, hour, minute };
653
666
  return { at, dst };
654
667
  }
@@ -706,7 +719,7 @@ function buildXuankong(input) {
706
719
  action: 'xuankong',
707
720
  period,
708
721
  periodLabel: yearInfo ? yearInfo.label : `${period}运`,
709
- ...(yearInfo ? { year: input.fortuneYear || (fortune && fortune.at.year), periodStart: yearInfo.start, periodEnd: yearInfo.end } : {}),
722
+ ...(yearInfo ? { year: input.fortuneYear ?? (fortune && fortune.at.year), periodStart: yearInfo.start, periodEnd: yearInfo.end } : {}),
710
723
  sitting: `${sitting.m}山(罗盘 ${sitting.deg}°)`,
711
724
  facing: `${facing.m}山(罗盘 ${facing.deg}°)`,
712
725
  sittingGua: sitting.gua,
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
  },