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
@@ -362,7 +362,7 @@ h3::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--li
362
362
  <div class="wrap">
363
363
  <header>
364
364
  <div class="brand-row">
365
- <div class="brand"><span class="seal" aria-hidden="true">命</span><span class="brand-txt">命理排盘<span class="ver">v0.62.1</span><small>八字 · 紫微 · 六爻 · 梅花 · 六壬 · 奇门 · 合盘 · 塔罗 全部在本机计算</small></span></div>
365
+ <div class="brand"><span class="seal" aria-hidden="true">命</span><span class="brand-txt">命理排盘<span class="ver">v0.62.2</span><small>八字 · 紫微 · 六爻 · 梅花 · 六壬 · 奇门 · 合盘 · 塔罗 全部在本机计算</small></span></div>
366
366
  <div class="view-switch">
367
367
  <span class="hint">熟练了可以换回平铺</span>
368
368
  <span class="seg" role="group" aria-label="首屏样式">
@@ -653,7 +653,7 @@ h3::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--li
653
653
  <h3>对拍:把典籍当期望值</h3>
654
654
  <ul><li>六爻:京房八宫卦序与世应,64 卦全表;旬空六旬表</li><li>六壬:日干寄宫、中气换将;紫微:纳音定五行局、紫微天府相照</li><li>卦例:《增删卜易》9 条 +《卜筮正宗》128 条(93 成对 + 35 静卦),核本变卦 / 动爻数 / 卦宫 / 缺爻 / 旬空</li><li>奇门:20 个时辰 × 9 宫,核四柱 / 局数 / 值符 / 值使 / 星门神 / 天盘干</li></ul>
655
655
  <h3>工程验证</h3>
656
- <ul><li>测试:154 个测试文件 / 7894 条断言;七道闸(回归 · 断言基线 · 测试可失败自检 · HTML 注入面 · 工程约定 · 产物宿主形态 · 随机扫射)</li><li>可复现:seed / asOf 显式;同一输入同一输出</li><li>口径:晚子时、真太阳时、奇门三流派、闰月归月都可切换,并有字段级差异对照</li></ul>
656
+ <ul><li>测试:155 个测试文件 / 8184 条断言;七道闸(回归 · 断言基线 · 测试可失败自检 · HTML 注入面 · 工程约定 · 产物宿主形态 · 随机扫射)</li><li>可复现:seed / asOf 显式;同一输入同一输出</li><li>口径:晚子时、真太阳时、奇门三流派、闰月归月都可切换,并有字段级差异对照</li></ul>
657
657
 
658
658
  <h3>已知边界(不猜)</h3>
659
659
  <ul><li>六爻应期(具体哪天应验)不做;卦例只录动爻个数,不录爻位</li><li>财富/健康只给结构判断与档位,不给金额区间、不作诊断</li><li>紫微飞星已做「单一支化逐宫追到成环」与「三方四正 16 条按落宫归堆」,但都未叠大限流年四化、不折吉凶;八字交运日已能算到日/时,但未与第三方排盘案例批量对拍</li></ul>
@@ -683,6 +683,206 @@ const fail = (code, message) => {
683
683
  return { fail };
684
684
  })();
685
685
 
686
+ __mods.bool = (function () {
687
+ /**
688
+ * 读参数里的布尔值只走这里。
689
+ *
690
+ * 本文件是引擎入口的「参数口径归一」:布尔(bool / canonicalFlags)与
691
+ * 数字(canonicalNumbers)各一道,盘面读的、句柄编码的、指纹比的是同一份值。
692
+ *
693
+ * 直接写 `args.cross === true` 会把字符串 "true" 与数字 1 都当成 false——
694
+ * 模型经 JSON 传参时这三种写法都出现过,界面层归一后仍可能有旧句柄或直接调用
695
+ * 引擎的路径。defaultValue 决定「没给值」时的口径(如 lateZi 默认 true)。
696
+ */
697
+ function bool(value, defaultValue = false) {
698
+ if (value === undefined || value === null || value === '') return defaultValue;
699
+ if (value === true || value === 1 || value === 'true' || value === '1') return true;
700
+ if (value === false || value === 0 || value === 'false' || value === '0') return false;
701
+ return defaultValue;
702
+ }
703
+
704
+ /**
705
+ * 引擎入口的开关归一表:键 → 未给值时的默认口径。
706
+ * 只收 schema 里声明为 boolean 的参数;盘面读值与句柄编码必须看到同一份值。
707
+ */
708
+ const FLAG_DEFAULTS = {
709
+ lateZi: true, trueSolar: true, dst: true,
710
+ isLeapMonth: false, leapAsNext: false,
711
+ cross: false, hourUnknown: false, diff: false, shadow: false,
712
+ trace: false, rectify: false, withInterpret: false,
713
+ };
714
+
715
+ /** 合盘对方 / 第三方对象里的同名开关(见 hepan、shadowPerson):键 → 未给值时的默认口径。
716
+ * hourUnknown 的缺省是 false(合盘遇到 true 会拦),不能跟着 lateZi 等默认开。 */
717
+ const PARTNER_FLAGS = {
718
+ lateZi: true,
719
+ trueSolar: true,
720
+ dst: true,
721
+ hourUnknown: false,
722
+ };
723
+
724
+ function canonicalInto(source, defaults) {
725
+ let out = source;
726
+ const put = (key, value) => {
727
+ if (Object.is(source[key], value)) return;
728
+ if (out === source) out = { ...source };
729
+ out[key] = value;
730
+ };
731
+ const drop = (key) => {
732
+ if (out === source) out = { ...source };
733
+ delete out[key];
734
+ };
735
+ for (const [key, fallback] of Object.entries(defaults)) {
736
+ // 没给这个键就一个键都不加:凭空补 true 会改掉所有旧句柄的身份
737
+ if (source[key] === undefined) continue;
738
+ // 空串 / 空白串按「没给」处理(宿主 normalizeArgs 也是这么收的):
739
+ // 各体系的缺省并不相同——奇门 lateZi 缺省 same-day、紫微 lateZishi 缺省 current-day,
740
+ // 若把 "" 折成全局缺省 true,同一次输入走工具层和直调引擎会排出两张盘。
741
+ if (typeof source[key] === 'string' && source[key].trim() === '') { drop(key); continue; }
742
+ put(key, bool(source[key], fallback));
743
+ }
744
+ return out;
745
+ }
746
+
747
+ function canonicalPartner(person) {
748
+ if (!person || typeof person !== 'object' || Array.isArray(person)) return person;
749
+ let out = person;
750
+ for (const [key, fallback] of Object.entries(PARTNER_FLAGS)) {
751
+ if (person[key] === undefined) continue;
752
+ if (typeof person[key] === 'string' && person[key].trim() === '') {
753
+ if (out === person) out = { ...person };
754
+ delete out[key];
755
+ continue;
756
+ }
757
+ const v = bool(person[key], fallback);
758
+ if (Object.is(person[key], v)) continue;
759
+ if (out === person) out = { ...person };
760
+ out[key] = v;
761
+ }
762
+ return out;
763
+ }
764
+
765
+ /**
766
+ * 把 args 里的字符串布尔 / 数字布尔统一成真布尔,返回**新对象**(无改动时返回原对象)。
767
+ *
768
+ * 为什么要在入口做一次:0.62.x 的盘面读开关用 `!== false`(只认字面 false),
769
+ * 句柄却直接编码传进来的原文。于是 `trueSolar:"false"` 排出「开真太阳时」的盘,
770
+ * 句柄里却写着 false——同一张盘两个身份,影子盘对拍、深链回放、跨版本 diff 全跟着错。
771
+ * 现在读值已统一走 `bool()`,这道归一仍要在入口做:它保证句柄与指纹拿到的
772
+ * 也是同一份真布尔,而不只是「读的时候碰巧算出同一个结果」。
773
+ */
774
+ function canonicalFlags(args) {
775
+ if (!args || typeof args !== 'object') return args;
776
+ let out = canonicalInto(args, FLAG_DEFAULTS);
777
+ for (const key of ['partner', 'partner2']) {
778
+ const person = out[key];
779
+ const fixed = canonicalPartner(person);
780
+ if (fixed !== person) {
781
+ if (out === args) out = { ...args };
782
+ out[key] = fixed;
783
+ }
784
+ }
785
+ return out;
786
+ }
787
+
788
+ // ── 数字归一 ────────────────────────────────────────────────
789
+ // 只把「整串就是一个数」的字符串转成数字:count:"3" → 3、period:"9" → 9。
790
+ // "9运" / "十" / 空串原样留着,让各体系原有的范围校验报 E_INPUT,
791
+ // 而不是先变成 NaN 再以「没给」的身份混过去。
792
+
793
+ /** schema 里 type 为 number / integer 的顶层键(options 由工具层摊平到顶层) */
794
+ const NUMERIC_KEYS = [
795
+ 'hour', 'minute', 'longitude', 'verifyPast', 'seed',
796
+ 'days', 'lunarYear', 'lunarMonth', 'lunarDay',
797
+ 'num1', 'num2', 'num3', 'upper', 'lower', 'changing',
798
+ 'count', 'toAge', 'fortuneYear', 'period', 'limit',
799
+ 'sittingOffset', 'sittingDegree', 'facingOffset', 'facingDegree',
800
+ 'year', 'month', 'day',
801
+ ];
802
+
803
+ /** 数值数组:逐项转,任一项不是数字就整组不动,交给原有校验报错 */
804
+ const NUMERIC_ARRAY_KEYS = ['lines', 'rectifyHours', 'answers'];
805
+
806
+ /** 字形 → 笔画的映射:值也是数字(起名的 strokeOverrides / surnameStrokes) */
807
+ const NUMERIC_MAP_KEYS = ['strokeOverrides', 'surnameStrokes'];
808
+
809
+ // 与宿主 normalizeArgs 同一把尺:指数写法(1e3)与尾点(5.)都算「整串是一个数」。
810
+ // 收窄成十进制会把 seed:"1e3" 留在字符串态——randomOf 照样按 1000 出牌,句柄却记着
811
+ // "1e3",同一张盘两个身份,正是这刀要消掉的东西。
812
+ const NUMERIC_RE = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
813
+
814
+ function numericOf(value) {
815
+ if (typeof value !== 'string') return value;
816
+ const s = value.trim();
817
+ if (!NUMERIC_RE.test(s)) return value;
818
+ const n = Number(s);
819
+ return Number.isFinite(n) ? n : value;
820
+ }
821
+
822
+ function canonicalMap(obj) {
823
+ let changed = false;
824
+ const next = {};
825
+ for (const [k, v] of Object.entries(obj)) {
826
+ const n = v === null ? null : numericOf(v);
827
+ if (!Object.is(n, v)) changed = true;
828
+ next[k] = n;
829
+ }
830
+ return changed ? next : obj;
831
+ }
832
+
833
+ /**
834
+ * 把数字串统一成数字,返回**新对象**(无改动时返回原对象)。
835
+ *
836
+ * 为什么也要在入口做一刀:句柄直接编码参数原文,`count:"3"` 与 `count:3`
837
+ * 抽出同一副牌、却拿到两个句柄——影子盘对拍、深链回放会认成两张盘。
838
+ * 另有两处不只影响身份,是实打实的算错:
839
+ * * 风水 `period:"9"` 被 1–9 校验拒掉,报错里还写着「收到「9」」,看着像误报;
840
+ * * 起名 `strokeOverrides:{"秋":"9"}` 被 overridesOf 静默丢掉,五格退回字库笔画,
841
+ * 用户给的笔画根本没参与计算。
842
+ */
843
+ function canonicalNumbers(args) {
844
+ if (!args || typeof args !== 'object') return args;
845
+ let out = args;
846
+ const put = (key, value) => {
847
+ if (Object.is(args[key], value)) return;
848
+ if (out === args) out = { ...args };
849
+ out[key] = value;
850
+ };
851
+ for (const key of NUMERIC_KEYS) {
852
+ if (args[key] === undefined) continue;
853
+ // 空串 / 空白串按「没给」处理(与宿主 normalizeArgs 一致)。留着会变成另一条路:
854
+ // 梅花数字起卦 Number("") = 0、风水 period 直接报「收到「」」——同一份输入两种结果。
855
+ if (typeof args[key] === 'string' && args[key].trim() === '') {
856
+ if (out === args) out = { ...args };
857
+ delete out[key];
858
+ continue;
859
+ }
860
+ put(key, numericOf(args[key]));
861
+ }
862
+ for (const key of NUMERIC_ARRAY_KEYS) {
863
+ const v = args[key];
864
+ if (!Array.isArray(v)) continue;
865
+ let changed = false;
866
+ const next = v.map((x) => {
867
+ if (x === null) return null;
868
+ if (typeof x === 'string' && x.trim() === '') { changed = true; return undefined; }
869
+ const n = numericOf(x);
870
+ if (!Object.is(n, x)) changed = true;
871
+ return n;
872
+ });
873
+ if (changed) put(key, next);
874
+ }
875
+ for (const key of NUMERIC_MAP_KEYS) {
876
+ const v = args[key];
877
+ if (!v || typeof v !== 'object' || Array.isArray(v)) continue;
878
+ put(key, canonicalMap(v));
879
+ }
880
+ return out;
881
+ }
882
+
883
+ return { bool, FLAG_DEFAULTS, canonicalFlags, NUMERIC_KEYS, NUMERIC_ARRAY_KEYS, NUMERIC_MAP_KEYS, canonicalNumbers };
884
+ })();
885
+
686
886
  __mods.diff = (function () {
687
887
  // 口径差异:同一组参数在不同流派口径下排出的盘,逐字段比对
688
888
  //
@@ -692,6 +892,8 @@ __mods.diff = (function () {
692
892
  //
693
893
  // 只做结构化比对,不改动任何引擎的输出。
694
894
 
895
+ const { bool } = (__mods.bool || {});
896
+
695
897
  /** 比对时忽略的字段:文案、时间戳、口径回显、内部儒略日数字 */
696
898
  const IGNORE_KEYS = new Set([
697
899
  'summary', 'interpret', 'castAt', 'asOf', 'precision', 'disclaimer', 'note',
@@ -962,7 +1164,7 @@ const CALIBER_SWITCHES = {
962
1164
  key: 'lateZi',
963
1165
  title: '晚子时:23:00 后是否换日',
964
1166
  short: '晚子时',
965
- read: (a) => a.lateZi !== false,
1167
+ read: (a) => bool(a.lateZi, true),
966
1168
  alt: (v) => !v,
967
1169
  label: (v) => (v ? '换日' : '不换日'),
968
1170
  },
@@ -970,7 +1172,7 @@ const CALIBER_SWITCHES = {
970
1172
  key: 'trueSolar',
971
1173
  title: '真太阳时:是否按出生地经度修正',
972
1174
  short: '真太阳时',
973
- read: (a) => a.trueSolar !== false,
1175
+ read: (a) => bool(a.trueSolar, true),
974
1176
  alt: (v) => !v,
975
1177
  label: (v) => (v ? '修正' : '不修正'),
976
1178
  },
@@ -988,7 +1190,7 @@ const CALIBER_SWITCHES = {
988
1190
  key: 'trueSolar',
989
1191
  title: '真太阳时:是否按出生地经度修正',
990
1192
  short: '真太阳时',
991
- read: (a) => a.trueSolar !== false,
1193
+ read: (a) => bool(a.trueSolar, true),
992
1194
  alt: (v) => !v,
993
1195
  label: (v) => (v ? '修正' : '不修正'),
994
1196
  },
@@ -1536,6 +1738,7 @@ return { earthL };
1536
1738
  __mods.calendar = (function () {
1537
1739
  const { earthL } = (__mods['vsop-earth'] || {});
1538
1740
  const { createLru } = (__mods.lru || {});
1741
+ const { bool } = (__mods.bool || {});
1539
1742
  // 历法底座:儒略日、干支、二十四节气、真太阳时
1540
1743
  // 全部为纯函数,不依赖 DOM / Node API,Host 与 Client 可共用。
1541
1744
 
@@ -1825,7 +2028,7 @@ function termYearList(year, jieOnly) {
1825
2028
  * @param {{jieOnly?: boolean}} [opts] jieOnly=true 时只返回十二节(default:false,含气)
1826
2029
  */
1827
2030
  function termWindow(year, opts = {}) {
1828
- const jieOnly = opts.jieOnly === true;
2031
+ const jieOnly = bool(opts.jieOnly);
1829
2032
  const cacheKey = jieOnly ? `${year}|jie` : year;
1830
2033
  const cached = TERM_WINDOW_CACHE.get(cacheKey);
1831
2034
  if (cached) return cached;
@@ -2059,6 +2262,7 @@ __mods.geo = (function () {
2059
2262
  // 坐标为近似值(±0.2°≈0.8 分钟时差),足够真太阳时使用。
2060
2263
  // 联网(Nominatim)是显式可选项:只有调用方传 online: true 才会把地址原文发出去,
2061
2264
  // 默认走内置表,不上传任何数据。
2265
+ const { bool } = (__mods.bool || {});
2062
2266
  const RAW = `
2063
2267
  北京 116.41 39.90;上海 121.47 31.23;天津 117.20 39.13;重庆 106.55 29.56
2064
2268
  广州 113.26 23.13;深圳 114.06 22.55;珠海 113.58 22.27;东莞 113.75 23.02;佛山 113.12 23.02
@@ -2286,7 +2490,7 @@ async function geocodeAddress(input, opts = {}) {
2286
2490
  const text = String(input || '').trim();
2287
2491
  if (!text) return local;
2288
2492
  // 默认离线:联网必须显式开启(会把出生地原文发给第三方)
2289
- const wantOnline = opts.online === true;
2493
+ const wantOnline = bool(opts.online);
2290
2494
  if (!wantOnline) return local;
2291
2495
  if (local && typeof local.lon === 'number' && !DETAIL.test(text)) return local;
2292
2496
  const remote = await onlineLookup(text, opts);
@@ -2328,22 +2532,36 @@ function mulberry32(seed) {
2328
2532
  };
2329
2533
  }
2330
2534
 
2535
+ // 与宿主 / bool.js 同一把尺:只认十进制(可带指数、尾点)。Number() 会把
2536
+ // "0x10"、false、[] 也变成有效数字,于是直接调引擎能排出盘、走工具层却被拒——
2537
+ // 同一份输入两种命运,正是句柄与复现最容易分叉的地方。
2538
+ const NUMERIC_TEXT = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
2539
+
2540
+ const seedNumber = (seed) => {
2541
+ if (typeof seed === 'number') return Number.isFinite(seed) ? seed : NaN;
2542
+ if (typeof seed === 'string') {
2543
+ const s = seed.trim();
2544
+ if (s !== '' && NUMERIC_TEXT.test(s)) return Number(s);
2545
+ }
2546
+ return NaN;
2547
+ };
2548
+
2331
2549
  /**
2332
2550
  * 从参数里取随机源:
2333
2551
  * - 没给 seed:沿用 Math.random(保持"真随机"的手感)
2334
- * - 给了数字或数字字符串:mulberry32(seed),可复现
2552
+ * - 给了数字或十进制数字串:mulberry32(seed),可复现
2335
2553
  * - 给了别的东西:调用方应当先报错,这里兜底回 Math.random
2336
2554
  */
2337
2555
  function randomOf(seed) {
2338
2556
  if (seed === undefined || seed === null || seed === '') return Math.random;
2339
- const n = Number(seed);
2557
+ const n = seedNumber(seed);
2340
2558
  return Number.isFinite(n) ? mulberry32(n) : Math.random;
2341
2559
  }
2342
2560
 
2343
2561
  /** 是否是一个可以接受的 seed */
2344
2562
  function isSeed(v) {
2345
2563
  if (v === undefined || v === null || v === '') return true;
2346
- return Number.isFinite(Number(v));
2564
+ return Number.isFinite(seedNumber(v));
2347
2565
  }
2348
2566
 
2349
2567
  return { mulberry32, randomOf, isSeed };
@@ -5317,6 +5535,7 @@ const { resolveAddress } = (__mods.geo || {});
5317
5535
  const { computeBaziShensha } = (__mods.shensha || {});
5318
5536
  const { assertCivil, assertShifted, daysInMonth } = (__mods.validate || {});
5319
5537
  const { applyDst } = (__mods.dst || {});
5538
+ const { bool } = (__mods.bool || {});
5320
5539
  const { nayinOf } = (__mods.nayin || {});
5321
5540
  const { SANHE } = (__mods.sanhe || {});
5322
5541
  const { analyzeBaziFrame, scoreLuck } = (__mods.geju || {});
@@ -5642,13 +5861,13 @@ function buildBazi(input) {
5642
5861
  let { year, month, day, hour = 0, minute = 0, gender = 'male' } = input;
5643
5862
  // 时辰未知:内部仍按「正午」跑完整条管线(大运、旺衰都要有个基准才能算),
5644
5863
  // 但对外一律不承认这个钟点——时柱置空、文本不出现具体时刻、精度降为按日。
5645
- const hourUnknown = input.hourUnknown === true;
5864
+ const hourUnknown = bool(input.hourUnknown);
5646
5865
  if (hourUnknown) { hour = 12; minute = 0; }
5647
- const lateZi = input.lateZi !== false;
5648
- const useTrueSolar = input.trueSolar !== false;
5866
+ const lateZi = bool(input.lateZi, true);
5867
+ const useTrueSolar = bool(input.trueSolar, true);
5649
5868
  // 1986–1991 中国夏令时:出生证钟面时间普遍是「拨快 1 小时」的钟面时间,
5650
5869
  // 不校正会让时柱(乃至跨 23:00 的日柱)整档偏;显式传 dst:false 可关闭。
5651
- const dst = input.dst === false ? { applied: false, note: null } : applyDst({ year, month, day, hour, minute });
5870
+ const dst = bool(input.dst, true) ? applyDst({ year, month, day, hour, minute }) : { applied: false, note: null };
5652
5871
  if (dst.applied) {
5653
5872
  year = dst.year; month = dst.month; day = dst.day; hour = dst.hour; minute = dst.minute;
5654
5873
  }
@@ -5752,7 +5971,7 @@ function buildBazi(input) {
5752
5971
 
5753
5972
  // 流年(默认从「今年」起排 10 年;传 input.nowYear 可钉死基准年,用于复现历史结论;
5754
5973
  // input.liunianTo 可把窗口延到指定年,生时校正要拿几十年前的事件逐年对账)
5755
- const thisYear = Number(input.nowYear) || new Date().getFullYear();
5974
+ const thisYear = Number(input.nowYear) || new Date().getFullYear(); // allow-clock: 未传 nowYear 时取本机年份;工具层 asOf 可钉死
5756
5975
  const liunianTo = Math.max(thisYear, Number(input.liunianTo) || thisYear + 9);
5757
5976
  const liunian = [];
5758
5977
  for (let y = thisYear; y <= liunianTo; y++) {
@@ -6931,6 +7150,7 @@ const { STEMS, BRANCHES, toJDN, ganzhiName, dayGanzhiIndex, sexagenaryIndex, xun
6931
7150
  const { jingText } = (__mods['yijing-text'] || {});
6932
7151
  const { fail } = (__mods.fail || {});
6933
7152
  const { assertObject } = (__mods.validate || {});
7153
+ const { randomOf, isSeed } = (__mods.random || {});
6934
7154
 
6935
7155
  // 八卦三位(自下而上,1 = 阳)
6936
7156
  const TRIGRAM_BITS = {
@@ -7263,10 +7483,8 @@ const sideOf = (position) => (position <= 3 ? 'inner' : 'outer');
7263
7483
  * @param {{direction?:string, gender?:string}} [opts]
7264
7484
  */
7265
7485
  function yongshenOf(result, opts = {}) {
7266
- // opts 允许省略或传 null(都按默认口径),但显式给了别的东西就必须是对象:
7486
+ opts = liuyaoOpts(opts, 'yongshenOf 的 opts', '{ direction?, gender? }');
7267
7487
  // 以前 yongshenOf(r, null) 会一路走到读 opts.direction,抛裸 TypeError
7268
- if (opts === null || opts === undefined) opts = {};
7269
- assertObject(opts, 'yongshenOf 的 opts', '{ direction?, gender? };省略或 null 按默认口径');
7270
7488
  const r = result && typeof result === 'object' ? result : {};
7271
7489
  const ben = r.ben && typeof r.ben === 'object' ? r.ben : {};
7272
7490
  const lines = Array.isArray(ben.lines) ? ben.lines : [];
@@ -7418,6 +7636,14 @@ function isMoving(v) {
7418
7636
  */
7419
7637
  function buildLiuyao(input = {}) {
7420
7638
  if (!input || typeof input !== 'object') throw fail('E_INPUT', '六爻起卦参数不能为空:至少给 lines 或 seed');
7639
+ // seed 以前只写在文档里:直接 buildLiuyao({ seed }) 仍走 Math.random,
7640
+ // 同一个 seed 摇不出同一卦,句柄却按 seed 算了身份。这里与工具层同一把尺。
7641
+ if (input.seed !== undefined && input.seed !== null && input.seed !== '' && !isSeed(input.seed)) {
7642
+ throw fail('E_INPUT', `seed 必须是数字,收到「${String(input.seed).slice(0, 24)}」`);
7643
+ }
7644
+ if (input.random !== undefined && input.random !== null && typeof input.random !== 'function') {
7645
+ throw fail('E_INPUT', `random 必须是函数,收到 ${typeof input.random}`);
7646
+ }
7421
7647
  // lines 给了但不是数组("7,8,9,6,7,8" / 789678 / {…})以前等同于「没给」:
7422
7648
  // 用户以为摇的是自己记下的那一卦,实际拿到一张随机盘,而且看不出来。
7423
7649
  if (input.lines !== undefined && input.lines !== null && !Array.isArray(input.lines)) {
@@ -7429,7 +7655,8 @@ function buildLiuyao(input = {}) {
7429
7655
  if (given && given.length !== 6) {
7430
7656
  throw fail('E_LINES', `lines 必须是 6 个爻值(自下而上),收到 ${given.length} 个`);
7431
7657
  }
7432
- const rawLines = given ? given.slice() : tossLines(input.random);
7658
+ const rand = typeof input.random === 'function' ? input.random : randomOf(input.seed);
7659
+ const rawLines = given ? given.slice() : tossLines(rand);
7433
7660
 
7434
7661
  const invalid = rawLines.filter((v) => ![6, 7, 8, 9].includes(v));
7435
7662
  if (invalid.length) throw fail('E_LINES', `爻值只能是 6/7/8/9,收到 ${JSON.stringify(rawLines)}`);
@@ -7441,7 +7668,7 @@ function buildLiuyao(input = {}) {
7441
7668
  const bian = bianBits ? hexagramFromBits(bianBits) : null;
7442
7669
 
7443
7670
  const dayGanZhi = input.dayGanZhi ?? (() => {
7444
- const now = new Date();
7671
+ const now = new Date(); // allow-clock: 未传日辰时按起卦当天取日干起六神;asOf 可钉死
7445
7672
  const jdn = toJDN(now.getFullYear(), now.getMonth() + 1, now.getDate());
7446
7673
  const g = ganzhiName(dayGanzhiIndex(jdn));
7447
7674
  return { stem: g.stem, branch: g.branch, name: g.name };
@@ -7590,6 +7817,12 @@ function dayRelationOf(element, dayElement) {
7590
7817
  return 'unknown';
7591
7818
  }
7592
7819
 
7820
+ /** opts 省略或传 null 都按默认口径;显式给了别的类型则当场报 E_INPUT。 */
7821
+ function liuyaoOpts(opts, who, shape) {
7822
+ if (opts === null || opts === undefined) return {};
7823
+ return assertObject(opts, who, `${shape};省略或 null 按默认口径`);
7824
+ }
7825
+
7593
7826
  /**
7594
7827
  * 用神旺衰:把月建、日辰两股外部力量落到用神那几爻上。
7595
7828
  * 月建按月支、日辰按日支,各出自己的五行,再与本爻五行论生克;
@@ -7599,6 +7832,7 @@ function dayRelationOf(element, dayElement) {
7599
7832
  * @param {{monthBranch?: string, dayBranch?: string}} opts
7600
7833
  */
7601
7834
  function yongshenStrength(result, useGod, opts = {}) {
7835
+ opts = liuyaoOpts(opts, 'yongshenStrength 的 opts', '{ monthBranch?, dayBranch? }');
7602
7836
  if (!useGod || !Array.isArray(useGod.found) || !useGod.found.length) return null;
7603
7837
  const monthBranch = opts.monthBranch || null;
7604
7838
  const dayBranch = opts.dayBranch || (result && result.dayGanZhi ? result.dayGanZhi.branch : null);
@@ -7675,6 +7909,7 @@ const FUSHEN_VERDICTS = ['可出', '有助有压', '受压难出', '未得助'];
7675
7909
  * @param {{monthBranch?: string, dayBranch?: string}} opts
7676
7910
  */
7677
7911
  function fushenAccess(result, useGod, opts = {}) {
7912
+ opts = liuyaoOpts(opts, 'fushenAccess 的 opts', '{ monthBranch?, dayBranch? }');
7678
7913
  if (!useGod || !Array.isArray(useGod.fushen) || !useGod.fushen.length) return null;
7679
7914
  const monthBranch = opts.monthBranch || null;
7680
7915
  const dayBranch = opts.dayBranch || (result && result.dayGanZhi ? result.dayGanZhi.branch : null);
@@ -7750,6 +7985,7 @@ function fushenAccess(result, useGod, opts = {}) {
7750
7985
  // 与用神旺衰同一套判定(月令五态、日辰五关系、三种冲法),区别只在不挑用神:
7751
7986
  // 六爻每一爻都给一次,世应、动爻、旬空另标,看得出整张卦的气压在哪一头。
7752
7987
  function hexagramStrength(result, opts = {}) {
7988
+ opts = liuyaoOpts(opts, 'hexagramStrength 的 opts', '{ monthBranch?, dayBranch? }');
7753
7989
  const rows = (result && result.ben && Array.isArray(result.ben.lines))
7754
7990
  ? result.ben.lines
7755
7991
  : (result && Array.isArray(result.lines) ? result.lines : []);
@@ -7838,6 +8074,7 @@ const TUI_VERDICT = { 旺: '退得缓', 相: '退得缓', 休: '退得实', 囚:
7838
8074
  * @param {{monthBranch?: string, dayBranch?: string}} opts
7839
8075
  */
7840
8076
  function jinTuiPower(result, opts = {}) {
8077
+ opts = liuyaoOpts(opts, 'jinTuiPower 的 opts', '{ monthBranch?, dayBranch? }');
7841
8078
  const items = result && result.body && Array.isArray(result.body.jinTui) ? result.body.jinTui : [];
7842
8079
  if (!items.length) return null;
7843
8080
  const rows = result && result.ben && Array.isArray(result.ben.lines) ? result.ben.lines : [];
@@ -7911,6 +8148,7 @@ const TRIGGER_ORDER = ['逢值', '逢冲', '逢合', '出空填实', '冲空'];
7911
8148
  * @param {{monthBranch?: string}} opts
7912
8149
  */
7913
8150
  function yingqiWindow(result, useGod, opts = {}) {
8151
+ opts = liuyaoOpts(opts, 'yingqiWindow 的 opts', '{ monthBranch? }');
7914
8152
  if (!useGod || !useGod.liuqin) return null;
7915
8153
  const found = Array.isArray(useGod.found) ? useGod.found : [];
7916
8154
  const fushen = Array.isArray(useGod.fushen) ? useGod.fushen : [];
@@ -8573,6 +8811,7 @@ const { nayinOf } = (__mods.nayin || {});
8573
8811
  const { resolveAddress } = (__mods.geo || {});
8574
8812
  const { placeZuoyao, zuoyaoVerseBlock, ZUOYAO_SKIPPED } = (__mods['ziwei-zuoyao'] || {});
8575
8813
  const { SHA_STAR } = (__mods['ziwei-words'] || {});
8814
+ const { bool } = (__mods.bool || {});
8576
8815
 
8577
8816
  const fix = (i) => ((i % 12) + 12) % 12;
8578
8817
 
@@ -8962,7 +9201,7 @@ function buildZiwei(input) {
8962
9201
  assertCivil(input);
8963
9202
  const schools = ziweiSchoolsOf(input);
8964
9203
  const { year: cy, month: cm, day: cd, hour: ch = 0, minute: cmin = 0, gender = 'male' } = input;
8965
- const useTrueSolar = input.trueSolar !== false;
9204
+ const useTrueSolar = bool(input.trueSolar, true);
8966
9205
  const geo = input.geo || (input.address ? resolveAddress(input.address) : null);
8967
9206
  const longitude = typeof input.longitude === 'number'
8968
9207
  ? input.longitude
@@ -9236,7 +9475,7 @@ function buildZiwei(input) {
9236
9475
  // 直接读挂钟的话,同一组参数(同一个句柄)跨年会排出不同的流年表,
9237
9476
  // 「同参数同盘」的承诺就只在当年成立。
9238
9477
  const asOfForLiunian = asOfParts(input.asOf);
9239
- const thisYear = (asOfForLiunian && asOfForLiunian.year) || new Date().getFullYear();
9478
+ const thisYear = (asOfForLiunian && asOfForLiunian.year) || new Date().getFullYear(); // allow-clock: 未传 asOf 时按本机年份起流年;工具层 asOf 可钉死
9240
9479
  const liunian = [];
9241
9480
  for (let y = thisYear; y < thisYear + 12; y++) {
9242
9481
  const gz = ganzhiName((((y - 4) % 60) + 60) % 60);
@@ -9888,7 +10127,7 @@ function asOfParts(v) {
9888
10127
  }
9889
10128
 
9890
10129
  function flowOf(chart, asOf) {
9891
- const now = new Date();
10130
+ const now = new Date(); // allow-clock: 流日/流时按调用时刻;显式 asOf 会覆盖
9892
10131
  const a = asOfParts(asOf) || {};
9893
10132
  const Y = a.year || now.getFullYear();
9894
10133
  const M = a.month || now.getMonth() + 1;
@@ -10387,12 +10626,17 @@ function tarotDeck() {
10387
10626
  // 名称 → 牌面数据(解读时按名字回查,保证确定性、且不依赖调用方是否带全字段)
10388
10627
  const BY_NAME = new Map(tarotDeck().map((c) => [c.name, c]));
10389
10628
 
10629
+ // 外部手输/旧数据常见的另一种译名。只做显式别名,不做模糊匹配:
10630
+ // 拼错的牌名不能被猜成另一张,否则解读会披着别的牌义,错得看不出来。
10631
+ const CARD_ALIASES = { 愚人: '愚者' };
10632
+ const cardMeta = (name) => BY_NAME.get(CARD_ALIASES[name] || name) || null;
10633
+
10390
10634
  const splitKeywords = (s) => String(s || '').split(/\s+/).filter(Boolean);
10391
10635
 
10392
10636
  /** 抽牌时刻(只做记录,不影响牌面) */
10393
10637
  function stamp(at) {
10394
10638
  if (typeof at === 'string' && at.trim()) return at.trim().replace('T', ' ').slice(0, 16);
10395
- const d = new Date();
10639
+ const d = new Date(); // allow-clock: 未传 asOf 时记录抽牌时刻;牌面由 seed 固定
10396
10640
  const p = (x) => String(x).padStart(2, '0');
10397
10641
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
10398
10642
  }
@@ -10416,12 +10660,17 @@ function tarotReading(input = {}) {
10416
10660
  if (!n) return ['没有抽到牌面,无法解读。'];
10417
10661
 
10418
10662
  const names = list.map((c) => c.name).join(' / ');
10663
+ // 牌库外的牌名不能悄悄读成「空牌义」:开场点名是哪几张,逐张处退回正/逆位泛义。
10664
+ const unknown = list.map((c) => c.name).filter((name) => !cardMeta(name));
10665
+ const unknownNote = unknown.length
10666
+ ? `其中「${unknown.join('」「')}」不在韦特 78 张牌库里,这几张只按你给的位置与正逆位泛读,不补牌面、元素与占星对应。`
10667
+ : '';
10419
10668
  const paras = [];
10420
10669
 
10421
10670
  // ① 开场:所问 + 象征系统声明
10422
10671
  paras.push(q
10423
- ? `你问的是「${q}」,抽到的是 ${n} 张牌阵(${names})。塔罗是一套象征系统而不是判决:不同流派对牌义、编号与逆位的取舍略有差异,这里按 RWS(韦特)牌名与黄金黎明的元素对应来读,请把下面的话当作镜子,对照自己的处境取用。`
10424
- : `这次没有具体提问,那就先看牌面本身在说什么。牌阵 ${n} 张(${names})。塔罗是象征系统,牌义随流派略有差异,这里按 RWS(韦特)与黄金黎明的常见对应来读,读到合用的部分就好。`);
10672
+ ? `你问的是「${q}」,抽到的是 ${n} 张牌阵(${names})。塔罗是一套象征系统而不是判决:不同流派对牌义、编号与逆位的取舍略有差异,这里按 RWS(韦特)牌名与黄金黎明的元素对应来读,请把下面的话当作镜子,对照自己的处境取用。${unknownNote}`
10673
+ : `这次没有具体提问,那就先看牌面本身在说什么。牌阵 ${n} 张(${names})。塔罗是象征系统,牌义随流派略有差异,这里按 RWS(韦特)与黄金黎明的常见对应来读,读到合用的部分就好。${unknownNote}`);
10425
10674
 
10426
10675
  // ② 逐张:位义 + 牌名/正逆 + 关键词 + 一句与位义结合的解读
10427
10676
  // 1-3 张时每张一段;4 张以上按前后两段分组(前段取 ceil(n/2) 张),
@@ -10431,8 +10680,10 @@ function tarotReading(input = {}) {
10431
10680
  for (const group of groups) {
10432
10681
  const lines = group.map((i) => {
10433
10682
  const c = list[i];
10434
- const meta = BY_NAME.get(c.name) || {};
10435
- const kws = (c.keywords && c.keywords.length ? c.keywords : splitKeywords(c.upright ? meta.up : meta.down)).join('、');
10683
+ const meta = cardMeta(c.name) || {};
10684
+ // 牌库外又没有关键词时,退回正/逆位的泛义,别留下「正位:。」的空句
10685
+ const kws = (c.keywords && c.keywords.length ? c.keywords : splitKeywords(c.upright ? meta.up : meta.down)).join('、')
10686
+ || (c.upright === false ? '逆位泛义(阻滞、内转或用力过头)' : '正位泛义(推进、显化、顺势)');
10436
10687
  const ori = c.upright === false ? '逆位' : '正位';
10437
10688
  const oriNote = c.upright === false
10438
10689
  ? '逆位并不等于凶,更像这股力量还没顺过来,或用力过了头'
@@ -10457,7 +10708,7 @@ function tarotReading(input = {}) {
10457
10708
  const upCount = n - revCount;
10458
10709
  const elementCount = {};
10459
10710
  for (const c of list) {
10460
- const e = (BY_NAME.get(c.name) || {}).element || c.element;
10711
+ const e = (cardMeta(c.name) || {}).element || c.element;
10461
10712
  if (e) elementCount[e] = (elementCount[e] || 0) + 1;
10462
10713
  }
10463
10714
  const present = ELEMENT_ORDER.filter((e) => elementCount[e]);
@@ -10486,7 +10737,7 @@ function tarotReading(input = {}) {
10486
10737
  const majorCard = list.find((c) => c.arcana === '大阿卡纳');
10487
10738
  const revCard = list.find((c) => c.upright === false);
10488
10739
  const key = majorCard || revCard || list[0];
10489
- const keyMeta = BY_NAME.get(key.name) || {};
10740
+ const keyMeta = cardMeta(key.name) || {};
10490
10741
  const keyReason = majorCard
10491
10742
  ? '它出现的地方,往往就是这件事真正被牵动的层级'
10492
10743
  : revCard
@@ -10559,7 +10810,9 @@ function drawTarot(input = {}) {
10559
10810
  keywords: splitKeywords(upright ? c.up : c.down),
10560
10811
  };
10561
10812
  });
10562
- const q = (question || '').trim();
10813
+ // 直接调引擎时 question 可能是数字(工具层 schema 的 string 不做强转),
10814
+ // 以前 (123 || '').trim() 会抛裸 TypeError,整张牌只剩一句内部错误。
10815
+ const q = String(question == null ? '' : question).trim();
10563
10816
  return {
10564
10817
  system: 'tarot',
10565
10818
  question: q || null,
@@ -10581,6 +10834,7 @@ __mods.meihua = (function () {
10581
10834
  const { BRANCHES, fromJDN, toJDN } = (__mods.calendar || {});
10582
10835
  const { solarToLunar } = (__mods.lunar || {});
10583
10836
  const { fail } = (__mods.fail || {});
10837
+ const { bool } = (__mods.bool || {});
10584
10838
  const { parseDateTime } = (__mods.datetime || {});
10585
10839
  const { assertShifted } = (__mods.validate || {});
10586
10840
  const { hexagramFromBits } = (__mods.liuyao || {});
@@ -10634,16 +10888,29 @@ function buildMeihua(input = {}) {
10634
10888
  let source;
10635
10889
 
10636
10890
  if (mode === 'number') {
10637
- const a = Number(input.upper);
10638
- const b = Number(input.lower);
10639
- if (!Number.isFinite(a) || !Number.isFinite(b)) throw fail('E_INPUT', '数字起卦需要两个数字(upper / lower),例如 upper=3、lower=5');
10891
+ // 只认真正的数字:Number(null) / Number(false) / Number([]) 都是 0,
10892
+ // 以前缺参或传错类型会静默起成「坤为地」,盘面看着正常却完全不是用户给的字数。
10893
+ const a = input.upper;
10894
+ const b = input.lower;
10895
+ // 小数以前只过 Number.isFinite,mod8(3.5) 得到 4.5,bitsOf 查不到卦再在深处抛
10896
+ // 「bitsOf is not a function」——参数错却报成内部错误。数字起卦只认整数。
10897
+ if (typeof a !== 'number' || typeof b !== 'number' || !Number.isInteger(a) || !Number.isInteger(b)) {
10898
+ throw fail('E_INPUT', '数字起卦需要两个整数(upper / lower),例如 upper=3、lower=5');
10899
+ }
10640
10900
  upperNum = mod8(a);
10641
10901
  lowerNum = mod8(b);
10642
10902
  if (input.changing !== undefined && input.changing !== null && input.changing !== '') {
10643
- // 以前 99 会被 mod 6 折成 3:用户指定动爻却得到另一爻,结果里看不出来
10644
- const ch = Number(input.changing);
10903
+ // 以前 99 会被 mod 6 折成 3:用户指定动爻却得到另一爻,结果里看不出来。
10904
+ // Number(true)=1、Number([3])=3 也会静默通过,故只收数字或十进制数字串。
10905
+ const raw = input.changing;
10906
+ const ch = typeof raw === 'number'
10907
+ ? raw
10908
+ : typeof raw === 'string' && /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/.test(raw.trim())
10909
+ ? Number(raw.trim())
10910
+ : NaN;
10645
10911
  if (!Number.isInteger(ch) || ch < 1 || ch > 6) {
10646
- throw fail('E_INPUT', `动爻 changing 必须是 1–6 的整数,收到「${input.changing}」`);
10912
+ const shown = typeof raw === 'string' ? raw : JSON.stringify(raw);
10913
+ throw fail('E_INPUT', `动爻 changing 必须是 1–6 的整数,收到「${shown}」`);
10647
10914
  }
10648
10915
  moving = ch;
10649
10916
  } else {
@@ -10653,7 +10920,7 @@ function buildMeihua(input = {}) {
10653
10920
  } else {
10654
10921
  const dt = parseDateTime(input.datetime);
10655
10922
  // 晚子时换日与八字/六壬/奇门同口径:23:00–23:59 归次日,年月日电数才落在同一子时
10656
- const lateZi = input.lateZi !== false;
10923
+ const lateZi = bool(input.lateZi, true);
10657
10924
  let sy = dt.year;
10658
10925
  let sm = dt.month;
10659
10926
  let sd = dt.day;
@@ -10739,6 +11006,7 @@ __mods.daliuren = (function () {
10739
11006
  const { toJDN, dayGanzhiIndex, ganzhiName, termWindow, formatJd, jdToParts, xunKongOf } = (__mods.calendar || {});
10740
11007
  const { assertCivil, assertShifted } = (__mods.validate || {});
10741
11008
  const { SANHE_ELEMENT } = (__mods.sanhe || {});
11009
+ const { bool } = (__mods.bool || {});
10742
11010
 
10743
11011
  const GAN = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'];
10744
11012
  const ZHI = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'];
@@ -11043,7 +11311,7 @@ function fanYin(ke, dayGan, dayZhi, stats, isFB) {
11043
11311
  function buildDaliuren(input) {
11044
11312
  assertCivil(input);
11045
11313
  const { year, month, day, hour = 0, minute = 0 } = input;
11046
- const lateZi = input.lateZi !== false;
11314
+ const lateZi = bool(input.lateZi, true);
11047
11315
  const jdnBase = toJDN(year, month, day);
11048
11316
  const jdn = lateZi && hour >= 23 ? jdnBase + 1 : jdnBase;
11049
11317
  const dayIdx = dayGanzhiIndex(jdn);
@@ -11153,6 +11421,7 @@ const { buildBazi, tenGod } = (__mods.bazi || {});
11153
11421
  const { STEMS, BRANCHES } = (__mods.calendar || {});
11154
11422
  const { assertCivil } = (__mods.validate || {});
11155
11423
  const { SANHE_GROUPS } = (__mods.sanhe || {});
11424
+ const { bool } = (__mods.bool || {});
11156
11425
 
11157
11426
  const WX_GAN = { 甲: '木', 乙: '木', 丙: '火', 丁: '火', 戊: '土', 己: '土', 庚: '金', 辛: '金', 壬: '水', 癸: '水' };
11158
11427
  const WX_ZHI = { 子: '水', 丑: '土', 寅: '木', 卯: '木', 辰: '土', 巳: '火', 午: '火', 未: '土', 申: '金', 酉: '金', 戌: '土', 亥: '水' };
@@ -11365,7 +11634,7 @@ function buildHepan(input) {
11365
11634
  tenGodToA: godB,
11366
11635
  text: he5
11367
11636
  ? `日干五合(${aStem}${bStem}),传统称「天干五合」,主彼此吸引强,也容易黏在一起分不开。`
11368
- : `日干无五合:你在对方盘里是「${godA}」,对方在你盘里是「${godB}」。`,
11637
+ : `日干无五合:对方在你盘里是「${godA}」,你在对方盘里是「${godB}」。`,
11369
11638
  },
11370
11639
  crossBranches: uniqCross.slice(0, 8),
11371
11640
  nayin: { a: nyA, b: nyB, label: nayinRel.name, text: nayinRel.text },
@@ -11387,7 +11656,7 @@ function buildHepan(input) {
11387
11656
  wuxing: wxA,
11388
11657
  // 真太阳时口径要能被看到:合盘与单盘用同一套修正,否则同一个人会出现两套四柱
11389
11658
  longitude: A.input.longitude ?? null,
11390
- trueSolar: A.input.trueSolar !== false,
11659
+ trueSolar: bool(A.input.trueSolar, true),
11391
11660
  solarNote: A.solarTime ? A.solarTime.note : '',
11392
11661
  summary: A.summary,
11393
11662
  },
@@ -11400,7 +11669,7 @@ function buildHepan(input) {
11400
11669
  favor: favorB.join(''),
11401
11670
  wuxing: wxB,
11402
11671
  longitude: B.input.longitude ?? null,
11403
- trueSolar: B.input.trueSolar !== false,
11672
+ trueSolar: bool(B.input.trueSolar, true),
11404
11673
  solarNote: B.solarTime ? B.solarTime.note : '',
11405
11674
  summary: B.summary,
11406
11675
  },
@@ -11428,6 +11697,7 @@ __mods.trend = (function () {
11428
11697
  // 口径:这是一套**简化启发式**——十神计分表固定、正负号随日主旺衰方向翻转,
11429
11698
  // 只用于看形态与相对起伏;分高不等于真实吉凶,旺衰落在换档边界时连排序都不给。
11430
11699
  const { buildBazi, HIDDEN_STEMS, tenGod } = (__mods.bazi || {});
11700
+ const { bool } = (__mods.bool || {});
11431
11701
  const { ganzhiName, sexagenaryIndex, STEMS, BRANCHES } = (__mods.calendar || {});
11432
11702
  const { fail } = (__mods.fail || {});
11433
11703
 
@@ -11531,10 +11801,13 @@ function buildTrend(input) {
11531
11801
  day: input.day,
11532
11802
  hour: Number.isInteger(input.hour) ? input.hour : 12,
11533
11803
  minute: Number.isInteger(input.minute) ? input.minute : 0,
11804
+ // 时辰未知必须一路传到 buildBazi:它在内部统一按正午占位并收回时柱。
11805
+ // 少了这一个键,摘要写着「按正午暂算」,旺衰却按 00:00 算成了另一档。
11806
+ hourUnknown: bool(input.hourUnknown),
11534
11807
  gender,
11535
- lateZi: input.lateZi !== false,
11536
- trueSolar: input.trueSolar !== false,
11537
- dst: input.dst !== false,
11808
+ lateZi: bool(input.lateZi, true),
11809
+ trueSolar: bool(input.trueSolar, true),
11810
+ dst: bool(input.dst, true),
11538
11811
  address: typeof input.address === 'string' ? input.address : undefined,
11539
11812
  longitude: typeof input.longitude === 'number' ? input.longitude : undefined,
11540
11813
  geo: input.geo && typeof input.geo === 'object' ? input.geo : undefined,
@@ -11650,6 +11923,8 @@ __mods.fengshui = (function () {
11650
11923
  const { partsToJd, termInstantJd, toJDN, dayGanzhiIndex, ganzhiName, termWindow, previousJie, BRANCHES } = (__mods.calendar || {});
11651
11924
  const { applyDst } = (__mods.dst || {});
11652
11925
  const { fail } = (__mods.fail || {});
11926
+ const { bool } = (__mods.bool || {});
11927
+ const { assertCivil, MIN_YEAR, MAX_YEAR } = (__mods.validate || {});
11653
11928
 
11654
11929
  /** 九星名称表:数 → {name, color, element, star(北斗名), lucky}。 */
11655
11930
  const NINE_STARS = {
@@ -11738,7 +12013,10 @@ const GUA_BY_NUM = ['坎', '坤', '震', '巽', '中', '乾', '兑', '艮', '离
11738
12013
  */
11739
12014
  function mingGua(birthYear, gender) {
11740
12015
  const tail = ((birthYear % 100) + 100) % 100;
11741
- const century = birthYear >= 2000 ? { male: 99, female: 3 } : { male: 100, female: 4 };
12016
+ // 世纪常数每个世纪减 1:1900s 男 100 / 女 4,2000s 99 / 3,2100s 98 / 2……
12017
+ // 以前只在「>=2000」时切一次 99/3,2100 年以后(引擎支持到 2400)会整档算错。
12018
+ const shift = Math.floor(birthYear / 100) - 19;
12019
+ const century = { male: 100 - shift, female: 4 - shift };
11742
12020
  const mod9 = (n) => { const r = ((n % 9) + 9) % 9; return r === 0 ? 9 : r; };
11743
12021
  let num = gender === 'male' ? mod9(century.male - tail) : mod9(tail - century.female);
11744
12022
  if (num === 5) num = gender === 'male' ? 2 : 8;
@@ -12084,9 +12362,10 @@ function xuankongChart(period, sitting, facing, opts = {}) {
12084
12362
 
12085
12363
  /** DST 校正出生日期(1986–1991),返回校正后的年月日。 */
12086
12364
  function civilDate(input) {
12087
- const dst = input.dst === false
12088
- ? { year: input.year, month: input.month, day: input.day, hour: 0, minute: 0, applied: false, note: null }
12089
- : applyDst({ year: input.year, month: input.month, day: input.day, hour: 0, minute: 0 });
12365
+ // 开启 DST 校正(默认开)才走 applyDst;显式关闭时不动日期
12366
+ const dst = bool(input.dst, true)
12367
+ ? applyDst({ year: input.year, month: input.month, day: input.day, hour: 0, minute: 0 })
12368
+ : { year: input.year, month: input.month, day: input.day, hour: 0, minute: 0, applied: false, note: null };
12090
12369
  return { year: dst.year, month: dst.month, day: dst.day, dst };
12091
12370
  }
12092
12371
 
@@ -12115,6 +12394,10 @@ function buildFengshui(input) {
12115
12394
  if (input.fortuneYear !== undefined && input.fortuneYear !== null && input.fortuneYear !== '') {
12116
12395
  const fy = Number(input.fortuneYear);
12117
12396
  if (!Number.isInteger(fy)) throw fail('E_INPUT', `fortuneYear 必须是整数年份,收到「${input.fortuneYear}」`);
12397
+ // 只校验「是整数」会放过 0 / 负数:年紫白照样按公式给出一颗星,看着像算过。
12398
+ if (fy < MIN_YEAR || fy > MAX_YEAR) {
12399
+ throw fail('E_INPUT', `fortuneYear 超出支持范围 ${MIN_YEAR}–${MAX_YEAR}(收到 ${fy})`);
12400
+ }
12118
12401
  input = { ...input, fortuneYear: fy };
12119
12402
  }
12120
12403
  const action = input.action;
@@ -12130,6 +12413,9 @@ function buildFengshui(input) {
12130
12413
  if (!Number.isInteger(input.year) || !Number.isInteger(input.month) || !Number.isInteger(input.day)) {
12131
12414
  throw fail('E_INPUT', '风水命卦需要完整出生年月日(公历)——只知道年份不够:命卦按立春界划分出生年');
12132
12415
  }
12416
+ // 与其它体系同一把尺:2 月 30 日、13 月、越界年份必须在入口报错,
12417
+ // 否则会被 Date 静默滚到下个月,命卦按另一天算出来却没人知道。
12418
+ assertCivil(input, '风水命卦');
12133
12419
  const civil = civilDate(input);
12134
12420
  const guaYear = guaYearOf(civil.year, civil.month, civil.day);
12135
12421
  const { num, gua, direction } = mingGua(guaYear, gender);
@@ -12292,7 +12578,7 @@ function parseFortuneAt(input) {
12292
12578
  }
12293
12579
  const [hour, minute] = rawTime.split(':').map(Number);
12294
12580
  if (hour > 23 || minute > 59) throw fail('E_INPUT', `fortuneTime 超出范围(HH:MM):「${rawTime}」`);
12295
- const dst = input.dst === false ? null : applyDst({ year, month, day, hour, minute });
12581
+ const dst = bool(input.dst, true) ? applyDst({ year, month, day, hour, minute }) : null;
12296
12582
  const at = dst ? { year: dst.year, month: dst.month, day: dst.day, hour: dst.hour, minute: dst.minute } : { year, month, day, hour, minute };
12297
12583
  return { at, dst };
12298
12584
  }
@@ -12350,7 +12636,7 @@ function buildXuankong(input) {
12350
12636
  action: 'xuankong',
12351
12637
  period,
12352
12638
  periodLabel: yearInfo ? yearInfo.label : `${period}运`,
12353
- ...(yearInfo ? { year: input.fortuneYear || (fortune && fortune.at.year), periodStart: yearInfo.start, periodEnd: yearInfo.end } : {}),
12639
+ ...(yearInfo ? { year: input.fortuneYear ?? (fortune && fortune.at.year), periodStart: yearInfo.start, periodEnd: yearInfo.end } : {}),
12354
12640
  sitting: `${sitting.m}山(罗盘 ${sitting.deg}°)`,
12355
12641
  facing: `${facing.m}山(罗盘 ${facing.deg}°)`,
12356
12642
  sittingGua: sitting.gua,
@@ -13241,6 +13527,7 @@ __mods.name = (function () {
13241
13527
  // 五格按康熙字典繁体笔画计(见 name-data.js 的口径说明),81 数理取通行本;
13242
13528
  // 音韵只做机械检查(同调、相邻同声母/同韵母、叠字),不做语义谐音判断。
13243
13529
  const { buildBazi } = (__mods.bazi || {});
13530
+ const { bool } = (__mods.bool || {});
13244
13531
  const { assertCivil, assertObject } = (__mods.validate || {});
13245
13532
  const { fail } = (__mods.fail || {});
13246
13533
  const { READING, SURNAME_STROKES, SURNAME_READING, SURNAME_ALT_READING, DOUBLE_SURNAMES, SHULI_81 } = (__mods['name-data'] || {});
@@ -13502,19 +13789,23 @@ const WX_REL = (a, b) => (a === b ? '同' : SHENG[a] === b ? '生' : KE[a] === b
13502
13789
 
13503
13790
  /** 数理超过 81 按传统「减 80 循环」取(如 104 → 24)。 */
13504
13791
  function shuliOf(n) {
13505
- let x = n;
13506
- while (x > 81) x -= 80;
13792
+ // 用取模代替 while 递减:strokeOverrides 允许用户给任意正整数,
13793
+ // 以前给 1e12 会在这里空转一百多亿次,整次排盘卡死。
13794
+ const x = n > 81 ? ((n - 1) % 80) + 1 : n;
13507
13795
  if (!(x >= 1)) return null;
13508
13796
  const row = SHULI_81[x];
13509
13797
  return row ? { num: x, level: row[0], name: row[1] } : null;
13510
13798
  }
13511
13799
 
13512
- /** 合并两处笔画覆盖:surnameStrokes(按姓给的)+ strokeOverrides(逐字覆盖),后者优先。 */
13800
+ /** 合并两处笔画覆盖:surnameStrokes(按姓给的)+ strokeOverrides(逐字覆盖),后者优先。
13801
+ * 笔画值允许数字串(`{"秋":"9"}`):模型经 JSON 传参时常见,只在入口归一还不够——
13802
+ * 这里再兜一层,免得「用户给了笔画却被静默按字库算」这种错法又冒出来。 */
13513
13803
  function overridesOf(input) {
13514
13804
  const out = {};
13515
13805
  for (const src of [input && input.surnameStrokes, input && input.strokeOverrides]) {
13516
13806
  if (!src || typeof src !== 'object') continue;
13517
- for (const [k, v] of Object.entries(src)) {
13807
+ for (const [k, raw] of Object.entries(src)) {
13808
+ const v = typeof raw === 'string' ? Number(raw.trim()) : raw;
13518
13809
  if (Number.isInteger(v) && v > 0) out[k] = v;
13519
13810
  }
13520
13811
  }
@@ -13742,12 +14033,14 @@ function assessName(input) {
13742
14033
  assertCivil(input, 'birth');
13743
14034
  const r = buildBazi({
13744
14035
  year: input.year, month: input.month, day: input.day,
13745
- hour: Number.isInteger(input.hour) ? input.hour : 12,
14036
+ // 只给日期时与 datetime:'YYYY-MM-DD' 同一口径(00:00 占位);引擎摘要会标明缺时刻,
14037
+ // 以前这里悄悄补 12:00,同一个生日走 datetime 和 year/month/day 会得到两档旺衰。
14038
+ hour: Number.isInteger(input.hour) ? input.hour : 0,
13746
14039
  minute: Number.isInteger(input.minute) ? input.minute : 0,
13747
14040
  gender: input.gender || 'male',
13748
- lateZi: input.lateZi !== false,
13749
- trueSolar: input.trueSolar !== false,
13750
- dst: input.dst !== false,
14041
+ lateZi: bool(input.lateZi, true),
14042
+ trueSolar: bool(input.trueSolar, true),
14043
+ dst: bool(input.dst, true),
13751
14044
  longitude: input.longitude, address: input.address, geo: input.geo,
13752
14045
  });
13753
14046
  strength = { level: r.strength.level, score: r.strength.score, dayElement: r.pillars.day.stemElement };
@@ -13843,12 +14136,13 @@ function generateNames(input) {
13843
14136
  const limit = Math.max(1, Math.min(20, Number.isInteger(input.limit) ? input.limit : 10));
13844
14137
  const r = buildBazi({
13845
14138
  year: input.year, month: input.month, day: input.day,
13846
- hour: Number.isInteger(input.hour) ? input.hour : 12,
14139
+ // 与 assessName 同一口径:缺时刻按 00:00 占位,由引擎摘要统一提示
14140
+ hour: Number.isInteger(input.hour) ? input.hour : 0,
13847
14141
  minute: Number.isInteger(input.minute) ? input.minute : 0,
13848
14142
  gender,
13849
- lateZi: input.lateZi !== false,
13850
- trueSolar: input.trueSolar !== false,
13851
- dst: input.dst !== false,
14143
+ lateZi: bool(input.lateZi, true),
14144
+ trueSolar: bool(input.trueSolar, true),
14145
+ dst: bool(input.dst, true),
13852
14146
  longitude: input.longitude, address: input.address, geo: input.geo,
13853
14147
  });
13854
14148
  const strength = { level: r.strength.level, score: r.strength.score, dayElement: r.pillars.day.stemElement };
@@ -16269,8 +16563,11 @@ function buildMbti(input = {}) {
16269
16563
  picked,
16270
16564
  strength: strengthOf(pct, answered),
16271
16565
  tie: sum === 0,
16566
+ unanswered: answered === 0,
16272
16567
  };
16273
16568
  });
16569
+ // 整维未答时 letter 只是 tie 占位;把它单独列出来,摘要与面板才不会当成「测出来了」
16570
+ const unanswered = dims.filter((d) => d.unanswered).map((d) => d.key);
16274
16571
  const type = dims.map((d) => d.picked).join('');
16275
16572
  const profile = TYPES[type];
16276
16573
  if (!profile) throw fail('E_SYSTEM', `内部错误:类型 ${type} 没有对应文案`);
@@ -16297,7 +16594,9 @@ function buildMbti(input = {}) {
16297
16594
  answered: answeredAll,
16298
16595
  skipped: QUESTIONS.length - answeredAll,
16299
16596
  total: QUESTIONS.length,
16300
- note: '四维各 6 题、李克特 1–5 计分,按维度净分取字母(平手时取 I / N / F / P 一侧并标「临界」)。这是 MBTI 风格的问卷测算,不是官方量表;类型只作自我参照,不是能力评价,也不用它给人下结论。',
16597
+ unanswered,
16598
+ note: '四维各 6 题、李克特 1–5 计分,按维度净分取字母(平手时取 I / N / F / P 一侧并标「临界」)。这是 MBTI 风格的问卷测算,不是官方量表;类型只作自我参照,不是能力评价,也不用它给人下结论。'
16599
+ + (unanswered.length ? ` 本轮 ${unanswered.join('、')} 维度一题未答,该维按平手取字母,只作占位,参考价值有限。` : ''),
16301
16600
  };
16302
16601
  }
16303
16602
 
@@ -16312,6 +16611,7 @@ const { ganzhiName, toJDN, fromJDN, dayGanzhiIndex } = (__mods.calendar || {});
16312
16611
  const { assertCivil, assertShifted } = (__mods.validate || {});
16313
16612
  const { hourToBranch } = (__mods.almanac || {});
16314
16613
  const { fail } = (__mods.fail || {});
16614
+ const { bool } = (__mods.bool || {});
16315
16615
 
16316
16616
  /** 六十甲子年骨重(单位:钱;索引 = (农历年 - 4) mod 60,0 = 甲子)。 */
16317
16617
  const YEAR_WEIGHT = [
@@ -16454,7 +16754,7 @@ function buildChenggu(input) {
16454
16754
  }
16455
16755
  // 晚子时换日与八字/六壬/奇门同口径:23:00–23:59 按次日取农历日与日柱,
16456
16756
  // 否则同一子时的前后两个钟点会差一天的日重
16457
- const lateZi = input.lateZi !== false;
16757
+ const lateZi = bool(input.lateZi, true);
16458
16758
  let sy = year;
16459
16759
  let sm = month;
16460
16760
  let sd = day;
@@ -16523,6 +16823,7 @@ __mods.xiaoliuren = (function () {
16523
16823
  // 小六壬(诸葛马前课):月/日/时(或报三个数)掐指落宫,六宫循环。
16524
16824
  // 口径:通行掌诀;断语为白话提炼(非原文照录)。轻量快占,属民俗参考。
16525
16825
  const { solarToLunar } = (__mods.lunar || {});
16826
+ const { bool } = (__mods.bool || {});
16526
16827
  const { hourToBranch } = (__mods.almanac || {});
16527
16828
  const { BRANCHES, fromJDN, toJDN } = (__mods.calendar || {});
16528
16829
  const { parseYmd } = (__mods.datetime || {});
@@ -16571,7 +16872,7 @@ function buildXiaoliuren(input) {
16571
16872
  let y = mt.year;
16572
16873
  let mo = mt.month;
16573
16874
  let dy = mt.day;
16574
- const lateZi = input.lateZi !== false;
16875
+ const lateZi = bool(input.lateZi, true);
16575
16876
  if (lateZi && input.hour >= 23) {
16576
16877
  const next = assertShifted(fromJDN(toJDN(y, mo, dy) + 1), '小六壬时间起课');
16577
16878
  y = next.year;
@@ -17929,6 +18230,7 @@ __mods.convert = (function () {
17929
18230
  const { solarToLunar } = (__mods.lunar || {});
17930
18231
  const { fromJDN, toJDN } = (__mods.calendar || {});
17931
18232
  const { buildAlmanac } = (__mods.almanac || {});
18233
+ const { bool } = (__mods.bool || {});
17932
18234
  const { assertCivil, assertObject, daysInMonth } = (__mods.validate || {});
17933
18235
  const { fail } = (__mods.fail || {});
17934
18236
 
@@ -18010,7 +18312,7 @@ function buildConvert(input) {
18010
18312
  if (m < 1 || m > 12) throw fail('E_INPUT', '农历月份须在 1–12');
18011
18313
  const months = lunarMonths(y);
18012
18314
  if (!months.length) throw fail('E_INPUT', `农历 ${y} 年无法定位正月,请核对年份(1600–2400)`);
18013
- const isLeap = input.isLeapMonth === true;
18315
+ const isLeap = bool(input.isLeapMonth);
18014
18316
  const hit = months.find((x) => x.month === m && x.isLeap === isLeap);
18015
18317
  if (!hit) {
18016
18318
  const leapMonths = months.filter((x) => x.isLeap).map((x) => `闰${MONTH_TEXT[x.month - 1]}月`);
@@ -22505,6 +22807,7 @@ __mods.rectify = (function () {
22505
22807
  // 2. 只有时柱(以及跨 23:00 时的日柱)会变,年/月柱通常不变,所以候选之间的差异集中在晚运、子女、事业后半段;
22506
22808
  // 3. 打分规则是显式、固定的(见 matchEvent),不是拟合;换一套规则排序可能变。
22507
22809
  const { buildBazi } = (__mods.bazi || {});
22810
+ const { bool } = (__mods.bool || {});
22508
22811
 
22509
22812
  const KINDS = ['career', 'wealth', 'love', 'move', 'health', 'other'];
22510
22813
  const HOURS = [0, 1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 23];
@@ -22619,9 +22922,9 @@ function rectifyBirth(opts) {
22619
22922
  hour,
22620
22923
  minute: 0,
22621
22924
  gender,
22622
- lateZi: opts.lateZi !== false,
22623
- trueSolar: opts.trueSolar !== false,
22624
- dst: opts.dst !== false,
22925
+ lateZi: bool(opts.lateZi, true),
22926
+ trueSolar: bool(opts.trueSolar, true),
22927
+ dst: bool(opts.dst, true),
22625
22928
  address: typeof opts.address === 'string' ? opts.address : undefined,
22626
22929
  longitude: typeof opts.longitude === 'number' ? opts.longitude : undefined,
22627
22930
  geo: opts.geo && typeof opts.geo === 'object' ? opts.geo : undefined,
@@ -22706,6 +23009,7 @@ const { PATTERN_TONE } = (__mods.ziwei || {});
22706
23009
  const { yongshenOf, yongshenStrength, fushenAccess } = (__mods.liuyao || {});
22707
23010
  const { qimenYongshen } = (__mods['qimen-interpret'] || {});
22708
23011
  const { GAN_JI_GONG } = (__mods.daliuren || {});
23012
+ const { bool } = (__mods.bool || {});
22709
23013
 
22710
23014
  const SYSTEM_LABEL = { liuyao: '六爻', bazi: '八字', ziwei: '紫微', qimen: '奇门', daliuren: '大六壬' };
22711
23015
 
@@ -23126,7 +23430,7 @@ function buildTrace(result, opts = {}) {
23126
23430
  if (result.system === 'liuyao') {
23127
23431
  let view = result;
23128
23432
  // 面板改了「询问方向」但不想重摇一卦:只按新方向重算用神相关三层
23129
- if (opts.refresh === true) {
23433
+ if (bool(opts.refresh)) {
23130
23434
  try {
23131
23435
  const ug = yongshenOf(result, { direction: opts.direction, gender: opts.gender });
23132
23436
  const monthBranch = (result.hexStrength && result.hexStrength.month && result.hexStrength.month.branch)
@@ -25016,6 +25320,7 @@ const { buildZiweiHepan } = (__mods['ziwei-hepan'] || {});
25016
25320
  const { buildQimen, buildSnapshot } = (__mods.qimen || {});
25017
25321
  const { geocodeAddress, resolveAddress } = (__mods.geo || {});
25018
25322
  const { toJDN, ganzhiName, dayGanzhiIndex } = (__mods.calendar || {});
25323
+ const { bool, canonicalFlags, canonicalNumbers } = (__mods.bool || {});
25019
25324
  const { randomOf, isSeed } = (__mods.random || {});
25020
25325
  const { encodeHandle, decodeHandle, resolveHandle, pruneForeignKeys, ownedKeys } = (__mods.handle || {});
25021
25326
  const { askLink, parseAskLink, formOf, restNote, askPackages } = (__mods.bridge || {});
@@ -25041,7 +25346,7 @@ const { buildFengshui } = (__mods.fengshui || {});
25041
25346
  const { buildName } = (__mods.name || {});
25042
25347
  const { buildTrace, traceMarkdown, SYSTEM_LABEL } = (__mods.trace || {});
25043
25348
  const { fail } = (__mods.fail || {});
25044
- const { assertCivil } = (__mods.validate || {});
25349
+ const { assertCivil, MIN_YEAR, MAX_YEAR } = (__mods.validate || {});
25045
25350
  const { parseDateTime, daysInMonth } = (__mods.datetime || {});
25046
25351
 
25047
25352
 
@@ -25051,6 +25356,25 @@ const pad = (n) => String(n).padStart(2, '0');
25051
25356
 
25052
25357
  const GENDERS = ['male', 'female'];
25053
25358
 
25359
+ /**
25360
+ * 时刻会改变盘面、且允许只给日期的体系:那时按 00:00 排,必须当场说清。
25361
+ * 黄历 / 择日 / 公农历转换只以「日」为输入,缺时刻是正常用法;
25362
+ * 六爻取日辰与月建定六神、旬空,不看钟点;梅花数字起卦也不看时间——都在名单外。
25363
+ * 称骨与小六壬缺时辰直接报错(时辰不可代填),同样不需要这句提示。
25364
+ */
25365
+ const HOUR_MATTERS = new Set(['bazi', 'ziwei', 'hepan', 'trend', 'meihua', 'daliuren', 'qimen', 'name']);
25366
+ /** 缺时刻时那句提示里要说清的「按什么排」,以及该怎么补(hourUnknown 只对八字/趋势有定义) */
25367
+ const NO_TIME_NOTE = {
25368
+ bazi: { what: '时柱', advice: '知道大概几点就补上,不知道就传 hourUnknown:true(只排三柱)' },
25369
+ ziwei: { what: '命宫与生时星曜', advice: '紫微排不出三柱,必须补上出生时刻' },
25370
+ hepan: { what: '两侧时柱', advice: '两侧都要出生时刻,合盘才有意义' },
25371
+ trend: { what: '时柱与起运', advice: '知道大概几点就补上,不知道就传 hourUnknown:true(按日估算)' },
25372
+ meihua: { what: '起卦时刻', advice: '时间起卦要用真实起念时刻,请补 HH:mm' },
25373
+ daliuren: { what: '起课时辰(月将与贵人)', advice: '请补起课时刻 HH:mm' },
25374
+ qimen: { what: '起局时辰(旬首与局数)', advice: '请补起局时刻 HH:mm' },
25375
+ name: { what: '时柱', advice: '请补出生时刻 HH:mm' },
25376
+ };
25377
+
25054
25378
  /**
25055
25379
  * 性别是必填项:它决定大运/大限的顺逆,缺省按某一方排等于给出一张错盘。
25056
25380
  * 之前用 `=== 'female' ? 'female' : 'male'` 兜底,静默把未填性别的人按男命排。
@@ -25518,14 +25842,14 @@ function shadowPerson(person = {}) {
25518
25842
  if (person.gender) out.gender = String(person.gender).toLowerCase();
25519
25843
  if (person.longitude !== undefined) out.longitude = num6(person.longitude);
25520
25844
  else if (person.address) out.address = String(person.address).trim();
25521
- if (person.lateZi !== undefined) out.lateZi = person.lateZi !== false;
25522
- if (person.trueSolar !== undefined) out.trueSolar = person.trueSolar !== false;
25523
- if (person.dst !== undefined) out.dst = person.dst !== false;
25845
+ if (person.lateZi !== undefined) out.lateZi = bool(person.lateZi, true);
25846
+ if (person.trueSolar !== undefined) out.trueSolar = bool(person.trueSolar, true);
25847
+ if (person.dst !== undefined) out.dst = bool(person.dst, true);
25524
25848
  return out;
25525
25849
  }
25526
25850
 
25527
25851
  function qimenLateMode(args) {
25528
- if (args.lateZiHourMode === 'next-day' || args.lateZi === true) return 'next-day';
25852
+ if (args.lateZiHourMode === 'next-day' || bool(args.lateZi)) return 'next-day';
25529
25853
  return 'same-day';
25530
25854
  }
25531
25855
 
@@ -25537,8 +25861,8 @@ function shadowParams(args, result) {
25537
25861
  civil: input.civil,
25538
25862
  gender: input.gender,
25539
25863
  longitude: num6(input.longitude),
25540
- lateZi: input.lateZi !== false,
25541
- trueSolar: input.trueSolar !== false,
25864
+ lateZi: bool(input.lateZi, true),
25865
+ trueSolar: bool(input.trueSolar, true),
25542
25866
  dst: !!(input.dst && input.dst.applied),
25543
25867
  };
25544
25868
  }
@@ -25548,9 +25872,9 @@ function shadowParams(args, result) {
25548
25872
  gender: input.gender,
25549
25873
  longitude: num6(input.longitude),
25550
25874
  leapMonth: input.leapMonth || 'split',
25551
- leapAsNext: input.leapAsNext === true,
25875
+ leapAsNext: bool(input.leapAsNext),
25552
25876
  schools: input.schools || ZIWEI_SCHOOL_DEFAULTS,
25553
- trueSolar: input.trueSolar !== false,
25877
+ trueSolar: bool(input.trueSolar, true),
25554
25878
  dst: !!(input.dst && input.dst.applied),
25555
25879
  };
25556
25880
  }
@@ -25636,7 +25960,10 @@ function normalizeAsOf(value) {
25636
25960
  const parts = asOfParts(s);
25637
25961
  if (!parts) return s; // 非法值留给 checkAsOf 报错,指纹不抢答
25638
25962
  const p2 = (n) => String(n).padStart(2, '0');
25639
- return `${parts.year}-${p2(parts.month)}-${p2(parts.day)}T${p2(parts.hour)}:${p2(parts.minute)}`;
25963
+ // 年份也要补到 4 位:asOfParts 的正则只认四位年份,0000 原样拼成 '0-01-01T12:00'
25964
+ // 会在 checkAsOf 里被当成格式错误,报出「无法解析 asOf:「0-01-01T12:00」」这种看不懂的话。
25965
+ const y4 = String(parts.year).padStart(4, '0');
25966
+ return `${y4}-${p2(parts.month)}-${p2(parts.day)}T${p2(parts.hour)}:${p2(parts.minute)}`;
25640
25967
  }
25641
25968
 
25642
25969
  /**
@@ -25753,6 +26080,14 @@ function compute(input = {}) {
25753
26080
  // 称骨句柄里的 hour 会跟到八字盘上,被工具层当成误传报错。
25754
26081
  args = pruneForeignKeys(args, ownedKeys(input, rev.patch));
25755
26082
  }
26083
+ // 开关归一:排盘读的、句柄记的、指纹比的是同一份真布尔。
26084
+ // 少了这一刀,`trueSolar:"false"` 会排出「开真太阳时」的盘而句柄写 false——
26085
+ // 同一张盘两个身份,影子盘对拍与深链回放都会错过。
26086
+ args = canonicalFlags(args);
26087
+ // 数字归一:`count:"3"` 与 `count:3` 是同一张盘,句柄与指纹也只能有一个身份。
26088
+ // 放在开关之后、asOf / 经度 / verifyPast 整形之前:后面几处校验读到的是真数字,
26089
+ // 「收到「9」」这种字面看着没错的报错也就不会再出现。
26090
+ args = canonicalNumbers(args);
25756
26091
  // asOf 在入口就归一成 canonical 串(去首尾空格、只给日期补 12:00):
25757
26092
  // 排盘、句柄、影子盘指纹读同一份。以前只有指纹 trim,句柄照抄原文,
25758
26093
  // '2030-01-01' 与 '2030-01-01T12:00' 出的明明是同一张盘,却各得一个 handle、一个 baseId。
@@ -25827,6 +26162,36 @@ ${r.summary}`;
25827
26162
  r.summary = `时柱暂缺:起运与大运按日估算,仅供参考
25828
26163
  ${r.summary}`;
25829
26164
  }
26165
+ // 只给日期、没给时刻:parseDateTime 会把时分补成 00:00,于是照常排出完整四柱/起好局,
26166
+ // 而调用方以为「日期就够了」。这类静默默认正是最该说出来的一种:盘面上看不出任何异常。
26167
+ // 梅花数字起卦只读 upper/lower,datetime 不参与取卦,不该被这句提示打扰。
26168
+ const ignoresClock = r && r.system === 'meihua' && args.mode === 'number';
26169
+ const datetimeDateOnly = typeof args.datetime === 'string' && args.datetime.trim() !== ''
26170
+ && !/[T\s]\d{1,2}:\d{1,2}/.test(args.datetime.trim());
26171
+ // name 还支持 year/month/day(+hour/minute) 分开给生辰:只给年月日时,
26172
+ // buildName 内部同样按 00:00 占位,不能因为没走 datetime 就绕过这句提示。
26173
+ const nameDateOnly = r && r.system === 'name' && !(typeof args.datetime === 'string' && args.datetime.trim())
26174
+ && Number.isInteger(args.year) && Number.isInteger(args.month) && Number.isInteger(args.day)
26175
+ && !Number.isInteger(args.hour);
26176
+ if (r && !ignoresClock && !r.hourUnknown && !r.timeMissing && HOUR_MATTERS.has(r.system)
26177
+ && (datetimeDateOnly || nameDateOnly) && r.summary) {
26178
+ r.timeMissing = true;
26179
+ const note = NO_TIME_NOTE[r.system] || { what: '时刻', advice: '请补上时刻 HH:mm' };
26180
+ r.summary = `⚠ 只给了日期、没有时刻:${note.what}按 00:00 暂算,与真实结果可能整档不同;${note.advice}\n${r.summary}`;
26181
+ }
26182
+ // 合盘的另一方同样是「只给日期」的静默 00:00,主方有提示、对方没有就等于只报了一半
26183
+ if (r && r.system === 'hepan' && r.summary) {
26184
+ const missing = [];
26185
+ for (const [who, label] of [['partner', '对方'], ['partner2', '第三方']]) {
26186
+ const person = args[who];
26187
+ const when = person && typeof person === 'object' ? person.datetime : undefined;
26188
+ if (typeof when === 'string' && !/[T\s]\d{1,2}:\d{1,2}/.test(when.trim())) missing.push(label);
26189
+ }
26190
+ if (missing.length) {
26191
+ r.timeMissing = true;
26192
+ r.summary = `⚠ 只给了日期、没有时刻:${missing.join('、')}的时柱按 00:00 暂算,与真实结果可能整档不同;请补出生时刻 HH:mm\n${r.summary}`;
26193
+ }
26194
+ }
25830
26195
  // 显式要求时才附加解析模块,避免默认输出过长;「所问之事」与「询问方向」一并传进去
25831
26196
  const tier = detailOf(args.detail);
25832
26197
  if (args.withInterpret) {
@@ -25836,13 +26201,14 @@ ${r.summary}`;
25836
26201
  });
25837
26202
  // 档位只改「回给模型的文本」,不改解析对象:面板拿到的仍是完整的 interpret。
25838
26203
  // 键名不用 detail / tier:合盘结果里 r.detail 是详批对象、流年行里 r.tier 是档位,都会撞。
25839
- if (tier === 'lean') r.interpretTier = 'lean';
26204
+ // 有些体系没有解析模块(interpret 为 null),此时不该留下一个没有正文的档位键。
26205
+ if (r.interpret && tier !== 'full') r.interpretTier = tier;
25840
26206
  }
25841
26207
  // 八字 × 紫微交叉验证:同一份出生信息再排另一套盘,并列两边对同一件事的说法
25842
26208
  // 交叉验证要再排另一套盘:时辰未知时紫微排不出来,这条支路直接跳过,
25843
26209
  // 不能拿正午排一张紫微盘来「交叉」,那是两张都不可信的盘互证。
25844
- if (args.cross === true && args.hourUnknown === true) r.crossSkipped = 'HOUR_UNKNOWN';
25845
- if (args.cross === true && args.hourUnknown !== true && (r.system === 'bazi' || r.system === 'ziwei')) {
26210
+ if (bool(args.cross) && bool(args.hourUnknown)) r.crossSkipped = 'HOUR_UNKNOWN';
26211
+ if (bool(args.cross) && !bool(args.hourUnknown) && (r.system === 'bazi' || r.system === 'ziwei')) {
25846
26212
  const cdt = parseDateTime(args.datetime);
25847
26213
  const common = {
25848
26214
  ...cdt,
@@ -25850,13 +26216,28 @@ ${r.summary}`;
25850
26216
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
25851
26217
  address: typeof args.address === 'string' ? args.address : undefined,
25852
26218
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
25853
- trueSolar: args.trueSolar !== false,
25854
- dst: args.dst !== false,
26219
+ trueSolar: bool(args.trueSolar, true),
26220
+ dst: bool(args.dst, true),
25855
26221
  };
25856
26222
  const asOf = checkAsOf(args.asOf);
26223
+ // 八字显式写了 lateZi 时,交叉的紫微盘必须同口径:23:00 后一边按次日、一边按当日,
26224
+ // 等于拿两个生日互证。没写时仍按紫微自己的 current-day 缺省,不改既有交叉结果;
26225
+ // 显式 lateZishi(紫微历史参数名)优先。
26226
+ const crossLateZishi = args.lateZishi !== undefined
26227
+ ? args.lateZishi
26228
+ : args.lateZi === undefined || args.lateZi === null || args.lateZi === ''
26229
+ ? undefined
26230
+ : bool(args.lateZi, true) ? 'next-day' : 'current-day';
25857
26231
  const other = r.system === 'bazi'
25858
- ? buildZiwei({ ...common, leapMonth: args.leapMonth, leapAsNext: args.leapAsNext === true, ...ziweiSchoolArgs(args), asOf })
25859
- : buildBazi({ ...common, lateZi: args.lateZi !== false, nowYear: asOf ? asOf.year : undefined });
26232
+ ? buildZiwei({
26233
+ ...common,
26234
+ leapMonth: args.leapMonth,
26235
+ leapAsNext: bool(args.leapAsNext),
26236
+ ...ziweiSchoolArgs(args),
26237
+ ...(crossLateZishi !== undefined ? { lateZishi: crossLateZishi } : {}),
26238
+ asOf,
26239
+ })
26240
+ : buildBazi({ ...common, lateZi: bool(args.lateZi, true), nowYear: asOf ? asOf.year : undefined });
25860
26241
  r.cross = crossCheck(r.system === 'bazi' ? r : other, r.system === 'ziwei' ? r : other);
25861
26242
  if (r.interpret) {
25862
26243
  r.interpret.sections.push(crossSection(r.cross));
@@ -25870,14 +26251,14 @@ ${r.summary}`;
25870
26251
  ...parseDateTime(args.datetime),
25871
26252
  // 与主盘同口径:主盘 hourUnknown 时内部按正午占位,过去盘若还用 00:00,
25872
26253
  // 流年信号就来自子夜的盘,与用户看到的不是同一张
25873
- hourUnknown: args.hourUnknown === true,
26254
+ hourUnknown: bool(args.hourUnknown),
25874
26255
  gender: requireGender(args.gender),
25875
- lateZi: args.lateZi !== false,
25876
- trueSolar: args.trueSolar !== false,
26256
+ lateZi: bool(args.lateZi, true),
26257
+ trueSolar: bool(args.trueSolar, true),
25877
26258
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
25878
26259
  address: typeof args.address === 'string' ? args.address : undefined,
25879
26260
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
25880
- dst: args.dst !== false,
26261
+ dst: bool(args.dst, true),
25881
26262
  nowYear: from,
25882
26263
  });
25883
26264
  r.past = {
@@ -25887,16 +26268,16 @@ ${r.summary}`;
25887
26268
  };
25888
26269
  }
25889
26270
  // 生时校正:候选时辰 × 过去事件,按固定规则打分(仅八字)
25890
- if (args.rectify === true && r.system === 'bazi') {
26271
+ if (bool(args.rectify) && r.system === 'bazi') {
25891
26272
  const base = parseDateTime(args.datetime);
25892
26273
  r.rectify = rectifyBirth({
25893
26274
  base: { year: base.year, month: base.month, day: base.day, minute: base.minute },
25894
26275
  gender: requireGender(args.gender),
25895
26276
  events: Array.isArray(args.events) ? args.events : [],
25896
26277
  hours: Array.isArray(args.rectifyHours) ? args.rectifyHours : undefined,
25897
- lateZi: args.lateZi !== false,
25898
- trueSolar: args.trueSolar !== false,
25899
- dst: args.dst !== false,
26278
+ lateZi: bool(args.lateZi, true),
26279
+ trueSolar: bool(args.trueSolar, true),
26280
+ dst: bool(args.dst, true),
25900
26281
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
25901
26282
  address: typeof args.address === 'string' ? args.address : undefined,
25902
26283
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
@@ -25905,7 +26286,7 @@ ${r.summary}`;
25905
26286
  // 追问:围绕已算好的盘回答「某一年 / 某方面」的具体问题
25906
26287
  if (typeof args.ask === 'string' && args.ask.trim()) r.followup = followup(r, args.ask);
25907
26288
  // 推导链:只读结果、不重算;生成失败不影响盘面,单独留一条错误信息
25908
- if (args.trace === true) {
26289
+ if (bool(args.trace)) {
25909
26290
  try {
25910
26291
  const layers = buildTrace(r, { direction: args.direction, gender: args.gender });
25911
26292
  if (layers) {
@@ -25918,7 +26299,7 @@ ${r.summary}`;
25918
26299
  }
25919
26300
  }
25920
26301
  // 影子盘 ID:同一组归一化参数 + 同一版本 → 同一个短 ID;显式要时才挂,避免默认输出变长
25921
- if (args.shadow === true) {
26302
+ if (bool(args.shadow)) {
25922
26303
  try {
25923
26304
  r.shadow = chartFingerprint(args, { result: r, version: args.shadowVersion });
25924
26305
  } catch (e) {
@@ -25936,11 +26317,17 @@ ${r.summary}`;
25936
26317
  }
25937
26318
 
25938
26319
  /** 解析与详解的紧凑文本(给模型看,不塞整块 JSON)
25939
- * detail:'full'(默认,与历史输出逐字一致)或 'lean'(只给结论、你问的那条与三张表,
25940
- * 长论述与「规则·出处」折成一行指针)。省的是回给模型的文本,不改盘面、不改解析对象本身。 */
26320
+ * detail:'full'(默认全文)/ 'lean'(只给结论、所问方向与三张表,长论述与规则出处折成指针)/
26321
+ * 'brief'(在 lean 上再限表行;工具层在 withInterpret 且未指定档位时用这一档)。
26322
+ * 省的是回给模型的文本,不改盘面、不改解析对象本身。
26323
+ * **不设体积上限**:brief 只做「少给哪些维度、表最多几行」这类结构性收敛,
26324
+ * 超长就让它超长——按字节截断会把结论、依据、边界一起腰斩,省下的体积不值这个代价。 */
25941
26325
  function interpretText(i, detail) {
25942
26326
  if (!i) return '';
26327
+ const brief = detail === 'brief';
25943
26328
  const lean = detail === 'lean';
26329
+ const compact = brief || lean;
26330
+ const BRIEF_TABLE_ROWS = 8;
25944
26331
  const LONG_SECTION = 300;
25945
26332
  const out = [`【${i.title || '解析与详解'}】${i.ask && i.ask.echo ? i.ask.echo : ''}`];
25946
26333
  // 直答放在最前:先回答「你问的事」,再说依据,避免读者读到最后才知道结论;
@@ -25956,12 +26343,12 @@ function interpretText(i, detail) {
25956
26343
  if (i.error) out.push(`⚠ 解析生成失败:${i.error}(上方盘面数据本身可用)`);
25957
26344
  const allDims = i.dimensions || [];
25958
26345
  // 问了方向就只给那一条:其余四条在 full 档里,模型要展开随时可以再排一次
25959
- const dims = lean && allDims.some((d) => d && d.focused) ? allDims.filter((d) => d && d.focused) : allDims;
26346
+ const dims = compact && allDims.some((d) => d && d.focused) ? allDims.filter((d) => d && d.focused) : allDims;
25960
26347
  for (const d of dims) {
25961
26348
  const evidence = Array.isArray(d.evidence) && d.evidence.length ? `\n 依据:${d.evidence.join(';')}${d.confidence ? ` 置信度:${d.confidence}` : ''}` : '';
25962
26349
  const plain = d.plain ? `(${d.plain})` : '';
25963
26350
  // 知识条目:「为什么这样断」+出处+可信度标注,是结论与依据之外的第三层
25964
- const know = lean
26351
+ const know = compact
25965
26352
  ? ((d.knowledge || []).length ? `\n 规则 ${d.knowledge.length} 条未展开(detail:"full" 给出原文与出处)` : '')
25966
26353
  : (d.knowledge || [])
25967
26354
  .map((k) => `\n 规则·${k.gist}〔${k.statusLabel}〕${k.detail} 出处:${k.source}${k.note ? ` 注:${k.note}` : ''}`)
@@ -25969,31 +26356,41 @@ function interpretText(i, detail) {
25969
26356
  out.push(`${d.label}:${plain}${d.text}${d.advice ? ` 建议:${d.advice}` : ''}${evidence}${know}`);
25970
26357
  }
25971
26358
  const secs = i.sections || [];
25972
- const longSecs = lean ? secs.filter((s) => String(s.text || '').length > LONG_SECTION) : [];
26359
+ const longSecs = compact ? secs.filter((s) => String(s.text || '').length > LONG_SECTION) : [];
25973
26360
  for (const s of secs) {
25974
- if (lean && String(s.text || '').length > LONG_SECTION) continue;
26361
+ if (compact && String(s.text || '').length > LONG_SECTION) continue;
25975
26362
  out.push(`${s.label}:${s.text}`);
25976
26363
  }
25977
26364
  if (longSecs.length) out.push(`(另有 ${longSecs.length} 节长论述未展开:${longSecs.map((s) => s.label).join('、')}。需要原文与出处时传 detail:"full")`);
25978
26365
  // 三张表:大运 / 流年 / 触发事件(纯文本对齐,工具输出也能直接读)
25979
26366
  for (const t of i.tables || []) {
25980
26367
  if (!Array.isArray(t.rows) || !t.rows.length) continue;
26368
+ let rows = t.rows;
25981
26369
  out.push(`【${t.title}】`);
25982
- out.push([t.columns.join(' | '), ...t.rows.map((r) => r.join(' | '))].join('\n'));
26370
+ if (brief && rows.length > BRIEF_TABLE_ROWS) {
26371
+ rows = rows.slice(0, BRIEF_TABLE_ROWS);
26372
+ out.push(`(共 ${t.rows.length} 行,仅列前 ${BRIEF_TABLE_ROWS} 行;全表传 detail:"lean")`);
26373
+ }
26374
+ out.push([t.columns.join(' | '), ...rows.map((r) => r.join(' | '))].join('\n'));
25983
26375
  }
25984
26376
  if (Array.isArray(i.followups) && i.followups.length) {
25985
26377
  out.push('还可以这样追问:' + i.followups.map((q) => `「${q}」`).join(' '));
25986
26378
  }
25987
- if (Array.isArray(i.glossary) && i.glossary.length) {
26379
+ if (!brief && Array.isArray(i.glossary) && i.glossary.length) {
25988
26380
  out.push('名词解释:' + i.glossary.map((g) => `${g.term}=${g.plain}`).join(' '));
25989
26381
  }
25990
26382
  // 能力边界:这段是给调用方(模型)看的上下文,不是给用户直接念的。
25991
26383
  // 写全比只写标题有用——模型知道「为什么不答」,才不会自己补一个答案出来。
26384
+ let text = '';
25992
26385
  if (Array.isArray(i.limits) && i.limits.length) {
25993
- out.push('【本插件不回答什么】' + i.limits.map((l) => `${l.title}——${l.text}`).join('\n'));
26386
+ text += `【本插件不回答什么】${i.limits.map((l) => `${l.title}——${l.text}`).join('\n')}\n`;
25994
26387
  }
25995
- if (i.disclaimer) out.push(i.disclaimer);
25996
- return out.join('\n');
26388
+ if (i.disclaimer) text += i.disclaimer;
26389
+ // 不按体积截断:这里只把「边界与免责声明」放在末尾,
26390
+ // 正文一个字都不砍——brief 已经用「少给维度、表限行数」控过长度了,
26391
+ // 再按字节腰斩只会把结论、依据和必要交代一起截掉,反而让模型读到半句话。
26392
+ const head = out.join('\n');
26393
+ return text ? `${head}\n${text}` : head;
25997
26394
  }
25998
26395
 
25999
26396
  /** 对照时不参与计算的参数:它们只影响解析/附加模块,不影响盘面 */
@@ -26113,12 +26510,12 @@ function normalizeLongitudes(args) {
26113
26510
  return out;
26114
26511
  }
26115
26512
 
26116
- /** 输出档位:缺省与 'full' 都是历史全文;'lean' 只回结论与你问的那一条,省模型上下文。
26117
- * 其它值以前会被当成「缺省」静默放过,写了 detail:'brief' 的人以为自己省了钱,其实没有。 */
26513
+ /** 输出档位:缺省 'full'(面板与直接调用保持历史全文);'lean' 去掉长论述与规则出处,
26514
+ * 'brief' 在 lean 上再限表行(不设体积上限)。工具层在 withInterpret 且未指定时传 brief。 */
26118
26515
  function detailOf(value) {
26119
- if (value === undefined || value === null || value === '' || value === 'full') return 'full';
26120
- if (value === 'lean') return 'lean';
26121
- throw fail('E_INPUT', `detail 只能是 'full' 或 'lean':收到「${String(value).slice(0, 24)}」`);
26516
+ if (value === undefined || value === null || value === '') return 'full';
26517
+ if (value === 'full' || value === 'lean' || value === 'brief') return value;
26518
+ throw fail('E_INPUT', `detail 只能是 'full' / 'lean' / 'brief':收到「${String(value).slice(0, 24)}」`);
26122
26519
  }
26123
26520
 
26124
26521
  /** asOf 只接受能解析成真实日期的字符串;非法值以前会静默回退到「此刻」 */
@@ -26129,6 +26526,11 @@ function checkAsOf(asOf) {
26129
26526
  const parts = asOfParts(asOf);
26130
26527
  if (!parts) throw fail('E_ASOF', `无法解析 asOf:「${asOf}」,请用 2026-10-22T16:00 这种格式`);
26131
26528
  const { year, month, day, hour, minute } = parts;
26529
+ // 与 parseDateTime / assertCivil 同一把年份尺:asOf 只校验月日时,1500 / 2500 / 9999
26530
+ // 会一路排进紫微流月流日,农历表却只覆盖 1600–2400,出来的是一张外推盘。
26531
+ if (year < MIN_YEAR || year > MAX_YEAR) {
26532
+ throw fail('E_ASOF', `asOf 的年份超出可计算范围(${MIN_YEAR}–${MAX_YEAR}):${asOf}`);
26533
+ }
26132
26534
  if (!(month >= 1 && month <= 12) || !(day >= 1 && day <= daysInMonth(year, month))) {
26133
26535
  throw fail('E_ASOF', `asOf 指向的日期不存在:${asOf}`);
26134
26536
  }
@@ -26158,6 +26560,17 @@ function computeRaw(args = {}) {
26158
26560
  throw fail('E_INPUT', `${system} 的 mode 只能是 ${allowed.join(' / ')},收到「${String(args.mode).slice(0, 24)}」`);
26159
26561
  }
26160
26562
  }
26563
+ // gender / direction 的枚举写在 schema 里,引擎直调也得守同一份契约:
26564
+ // 六爻曾把「女」静默当 male 出用神,direction 拼错则静默按 general——
26565
+ // 换一个用神、换一套断法,盘面却看不出任何异常。
26566
+ if (args.gender !== undefined && args.gender !== null && args.gender !== ''
26567
+ && args.gender !== 'male' && args.gender !== 'female') {
26568
+ throw fail('E_INPUT', `gender 只能是 male / female,收到「${String(args.gender).slice(0, 24)}」`);
26569
+ }
26570
+ if (args.direction !== undefined && args.direction !== null && args.direction !== ''
26571
+ && !['general', 'wealth', 'career', 'love', 'health', 'study', 'travel'].includes(args.direction)) {
26572
+ throw fail('E_INPUT', `direction 只能是 general / wealth / career / love / health / study / travel,收到「${String(args.direction).slice(0, 24)}」`);
26573
+ }
26161
26574
  // 风水 fortuneYear 以前只认整数:2026.5 被静默当成「没给」,整块年飞星消失。
26162
26575
  if (args.fortuneYear !== undefined && args.fortuneYear !== null && args.fortuneYear !== '') {
26163
26576
  const n = Number(args.fortuneYear);
@@ -26175,25 +26588,25 @@ function computeRaw(args = {}) {
26175
26588
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
26176
26589
  address: typeof args.address === 'string' ? args.address : undefined,
26177
26590
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
26178
- lateZi: args.lateZi !== false,
26179
- trueSolar: args.trueSolar !== false,
26180
- dst: args.dst !== false,
26591
+ lateZi: bool(args.lateZi, true),
26592
+ trueSolar: bool(args.trueSolar, true),
26593
+ dst: bool(args.dst, true),
26181
26594
  // 流年表默认从「今年」起排;要复现历史结论就把基准年钉死
26182
26595
  nowYear: asOf ? asOf.year : undefined,
26183
26596
  // 时辰未知:三柱模式(时柱不排、精度降到日、摘要首行固定提示)
26184
- hourUnknown: args.hourUnknown === true,
26597
+ hourUnknown: bool(args.hourUnknown),
26185
26598
  });
26186
26599
  return { ...result, ...(asOf ? { asOf } : {}), precision: dt.precision, summary: baziText(result) };
26187
26600
  }
26188
26601
  if (system === 'ziwei') {
26189
26602
  // 命宫由「生月 + 生时支」定:时支未知则整盘无处落脚,没有「大概的紫微盘」这种东西
26190
- if (args.hourUnknown === true) {
26603
+ if (bool(args.hourUnknown)) {
26191
26604
  throw fail('HOUR_UNKNOWN', '紫微必须有出生时刻:命宫由生月与生时支定,时支未知则整盘不成立。请补上出生时刻,或改用八字三柱模式只看年/月/日。');
26192
26605
  }
26193
26606
  // 与奇门同一个别名口径:lateZi 是八字/六壬的参数名,紫微的历史参数名是 lateZishi。
26194
26607
  // 不归一的话 revise「晚子时算次日」会照常回执已应用,盘却按默认 current-day 排。
26195
26608
  if (args.lateZi !== undefined && args.lateZishi === undefined) {
26196
- args = { ...args, lateZishi: args.lateZi === false ? 'current-day' : 'next-day' };
26609
+ args = { ...args, lateZishi: bool(args.lateZi, true) ? 'next-day' : 'current-day' };
26197
26610
  }
26198
26611
  const dt = parseDateTime(args.datetime);
26199
26612
  const asOf = checkAsOf(args.asOf);
@@ -26201,13 +26614,13 @@ function computeRaw(args = {}) {
26201
26614
  ...dt,
26202
26615
  gender: requireGender(args.gender),
26203
26616
  leapMonth: args.leapMonth,
26204
- leapAsNext: args.leapAsNext === true,
26617
+ leapAsNext: bool(args.leapAsNext),
26205
26618
  ...ziweiSchoolArgs(args),
26206
26619
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
26207
26620
  address: typeof args.address === 'string' ? args.address : undefined,
26208
26621
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
26209
- trueSolar: args.trueSolar,
26210
- dst: args.dst !== false,
26622
+ trueSolar: bool(args.trueSolar, true),
26623
+ dst: bool(args.dst, true),
26211
26624
  // 流月/流日/流时的基准时刻:默认本机此刻,可用 asOf 指定以便复现
26212
26625
  asOf,
26213
26626
  });
@@ -26227,7 +26640,7 @@ function computeRaw(args = {}) {
26227
26640
  }
26228
26641
  if (system === 'daliuren') {
26229
26642
  const dt = parseDateTime(args.datetime);
26230
- const lateZi = args.lateZi !== false;
26643
+ const lateZi = bool(args.lateZi, true);
26231
26644
  const result = buildDaliuren({ ...dt, lateZi });
26232
26645
  result.lateZi = lateZi;
26233
26646
  return { ...result, precision: dt.precision, summary: daliurenText(result) };
@@ -26241,7 +26654,7 @@ function computeRaw(args = {}) {
26241
26654
  // lateZiHourMode 是奇门的历史参数名,lateZi 是八字/六壬的;两者互为别名。
26242
26655
  const mode = args.lateZiHourMode !== undefined
26243
26656
  ? args.lateZiHourMode
26244
- : args.lateZi === true ? 'next-day' : args.lateZi === false ? 'same-day' : undefined;
26657
+ : args.lateZi === undefined || args.lateZi === null ? undefined : bool(args.lateZi) ? 'next-day' : 'same-day';
26245
26658
  if (mode !== undefined && !LATE_ZI_MODES.includes(mode)) {
26246
26659
  throw fail('E_INPUT', `lateZiHourMode 只能是 same-day / next-day,收到「${mode}」`);
26247
26660
  }
@@ -26251,9 +26664,14 @@ function computeRaw(args = {}) {
26251
26664
  return { ...result, summary: qimenText(result) };
26252
26665
  }
26253
26666
  if (system === 'hepan') {
26254
- // 双方夫妻宫、大运都依赖时支:任一方时辰未知就不出盘,免得「差不多」的合盘误导决定
26255
- if (args.hourUnknown === true || (args.partner && args.partner.hourUnknown === true)) {
26256
- throw fail('HOUR_UNKNOWN', '合盘需要双方都有出生时刻:夫妻宫与大运都依赖时支。请补齐时刻后再看,或用八字三柱模式各自看年/月/日。');
26667
+ // 双方(以及可选的第三方)夫妻宫、大运都依赖时支:任一方时辰未知就不出盘,
26668
+ // 免得「差不多」的合盘误导决定。第三方只在给了 partner2 时才参与校验,
26669
+ // 但一旦参与,口径必须和主方、对方一致——不能主方缺时柱就拦、第三方缺时柱却照排。
26670
+ const unknownSide = bool(args.hourUnknown)
26671
+ || (args.partner && bool(args.partner.hourUnknown))
26672
+ || (args.partner2 && bool(args.partner2.hourUnknown));
26673
+ if (unknownSide) {
26674
+ throw fail('HOUR_UNKNOWN', '合盘需要每一方都有出生时刻:夫妻宫与大运都依赖时支。请补齐时刻后再看,或用八字三柱模式各自看年/月/日。');
26257
26675
  }
26258
26676
  const partner = args.partner;
26259
26677
  if (!partner || typeof partner !== 'object') {
@@ -26275,12 +26693,23 @@ function computeRaw(args = {}) {
26275
26693
  // 性别决定双方大运/大限顺逆,两边都不能用默认值
26276
26694
  const sideOf = (src, who) => {
26277
26695
  const dt = parseDateTime(src.datetime);
26696
+ // 紫微读的是历史参数名 lateZishi,合盘这一层若不转写,`chart:'ziwei', lateZi:false`
26697
+ // 会排出与 true 完全相同的盘(八字合盘同一开关却生效)。
26698
+ const lateZishi = src.lateZishi !== undefined
26699
+ ? src.lateZishi
26700
+ : src.lateZi === undefined || src.lateZi === null || src.lateZi === ''
26701
+ ? undefined
26702
+ : bool(src.lateZi, true) ? 'next-day' : 'current-day';
26278
26703
  return {
26279
26704
  ...dt,
26280
26705
  gender: requireGender(src.gender, who),
26281
26706
  trueSolar: src.trueSolar,
26282
26707
  lateZi: src.lateZi,
26283
- dst: src.dst !== false,
26708
+ ...(lateZishi !== undefined ? { lateZishi } : {}),
26709
+ // 紫微的闰月口径同属「读盘时才算」的参数,漏传会让合盘与单盘在闰月出生时差一个月
26710
+ ...(src.leapMonth !== undefined ? { leapMonth: src.leapMonth } : {}),
26711
+ ...(src.leapAsNext !== undefined ? { leapAsNext: bool(src.leapAsNext) } : {}),
26712
+ dst: bool(src.dst, true),
26284
26713
  address: typeof src.address === 'string' ? src.address : undefined,
26285
26714
  longitude: typeof src.longitude === 'number' ? src.longitude : undefined,
26286
26715
  geo: src.geo && typeof src.geo === 'object' ? src.geo : undefined,
@@ -26434,7 +26863,7 @@ function computeRaw(args = {}) {
26434
26863
  lunarYear: Number(args.lunarYear),
26435
26864
  lunarMonth: Number(args.lunarMonth),
26436
26865
  lunarDay: Number(args.lunarDay),
26437
- isLeapMonth: args.isLeapMonth === true,
26866
+ isLeapMonth: bool(args.isLeapMonth),
26438
26867
  });
26439
26868
  } else {
26440
26869
  const dt = parseDateTime(args.datetime);
@@ -26485,18 +26914,18 @@ function computeRaw(args = {}) {
26485
26914
  nowYear: nowParts ? nowParts.year : undefined,
26486
26915
  gender: requireGender(args.gender),
26487
26916
  toAge: args.toAge,
26488
- lateZi: args.lateZi !== false,
26489
- trueSolar: args.trueSolar !== false,
26490
- dst: args.dst !== false,
26917
+ lateZi: bool(args.lateZi, true),
26918
+ trueSolar: bool(args.trueSolar, true),
26919
+ dst: bool(args.dst, true),
26491
26920
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
26492
26921
  address: typeof args.address === 'string' ? args.address : undefined,
26493
26922
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
26494
- hourUnknown: args.hourUnknown === true,
26923
+ hourUnknown: bool(args.hourUnknown),
26495
26924
  });
26496
26925
  // 趋势没有独立的 dayun 字段(大运在 buildTrend 内部用),所以精度标记挂在结果顶层
26497
26926
  return {
26498
26927
  ...result,
26499
- ...(args.hourUnknown === true ? { hourUnknown: true, hourPrecision: 'day' } : {}),
26928
+ ...(bool(args.hourUnknown) ? { hourUnknown: true, hourPrecision: 'day' } : {}),
26500
26929
  precision: dt.precision,
26501
26930
  summary: trendText(result),
26502
26931
  };
@@ -26519,7 +26948,7 @@ function computeRaw(args = {}) {
26519
26948
  ...(typeof args.facingDegree === 'number' ? { facingDegree: args.facingDegree } : {}),
26520
26949
  ...(typeof args.fortuneDate === 'string' ? { fortuneDate: args.fortuneDate } : {}),
26521
26950
  ...(typeof args.fortuneTime === 'string' ? { fortuneTime: args.fortuneTime } : {}),
26522
- dst: args.dst !== false,
26951
+ dst: bool(args.dst, true),
26523
26952
  });
26524
26953
  return { ...result, summary: fengshuiText(result) };
26525
26954
  }
@@ -26530,7 +26959,7 @@ function computeRaw(args = {}) {
26530
26959
  facing: args.facing,
26531
26960
  ...(args.datetime ? parseDateTime(args.datetime) : {}),
26532
26961
  ...(args.gender === 'male' || args.gender === 'female' ? { gender: args.gender } : {}),
26533
- dst: args.dst !== false,
26962
+ dst: bool(args.dst, true),
26534
26963
  });
26535
26964
  return { ...result, summary: fengshuiText(result) };
26536
26965
  }
@@ -26540,7 +26969,7 @@ function computeRaw(args = {}) {
26540
26969
  year: dt.year, month: dt.month, day: dt.day,
26541
26970
  gender: requireGender(args.gender),
26542
26971
  fortuneYear: args.fortuneYear,
26543
- dst: args.dst !== false,
26972
+ dst: bool(args.dst, true),
26544
26973
  });
26545
26974
  return { ...result, summary: fengshuiText(result) };
26546
26975
  }
@@ -26569,9 +26998,9 @@ function computeRaw(args = {}) {
26569
26998
  ...birth,
26570
26999
  ...(args.gender === 'male' || args.gender === 'female' ? { gender: args.gender } : {}),
26571
27000
  ...(args.datetime ? {
26572
- lateZi: args.lateZi !== false,
26573
- trueSolar: args.trueSolar !== false,
26574
- dst: args.dst !== false,
27001
+ lateZi: bool(args.lateZi, true),
27002
+ trueSolar: bool(args.trueSolar, true),
27003
+ dst: bool(args.dst, true),
26575
27004
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
26576
27005
  address: typeof args.address === 'string' ? args.address : undefined,
26577
27006
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
@@ -26717,8 +27146,11 @@ function mbtiText(r) {
26717
27146
  const stack = Array.isArray(p.stack) && p.stack.length
26718
27147
  ? p.stack.map((f, i) => `${i === 0 ? '主导 ' : ''}${f.code}`).join(' → ') + `\n ${p.stack.map((f) => `${f.code}:${f.say}`).join('\n ')}`
26719
27148
  : '';
27149
+ const missing = Array.isArray(r.unanswered) && r.unanswered.length
27150
+ ? ` 未答维度:${r.unanswered.join('、')}(该维按平手占位)`
27151
+ : '';
26720
27152
  return [
26721
- `【十六型人格】${r.type}(${p.title})· ${p.keywords} 已答 ${r.answered}/${r.total} 题`,
27153
+ `【十六型人格】${r.type}(${p.title})· ${p.keywords} 已答 ${r.answered}/${r.total} 题${missing}`,
26722
27154
  `一句:${p.oneLine}`,
26723
27155
  '【四维】',
26724
27156
  ...dims,
@@ -26892,6 +27324,8 @@ function renderText(result) {
26892
27324
  parts.push(`【推导链】${SYSTEM_LABEL[result.system] || result.system} · ${result.trace.length} 层\n`
26893
27325
  + result.trace.map((t, i) => `${i + 1}. ${t.field}|${t.result}\n 规则:${t.rule} 出处:${t.source}`).join('\n'));
26894
27326
  }
27327
+ // 解析段按档位渲染。brief 只做结构性收敛(少给维度、表限行数),不压体积:
27328
+ // 盘面、句柄、解析全都不裁,这段有多长就多长。
26895
27329
  if (result.interpret) {
26896
27330
  const extra = interpretText(result.interpret, result.interpretTier);
26897
27331
  if (extra) parts.push(extra);
@@ -26962,7 +27396,7 @@ return { interpret, DIRECTIONS, DIRECTION_DEFAULT, BRIGHTNESS, flowOf, parseDate
26962
27396
  })();
26963
27397
 
26964
27398
  window.Engine = __mods.index;
26965
- window.__MP_VER = "0.62.1";
27399
+ window.__MP_VER = "0.62.2";
26966
27400
  </script>
26967
27401
  <script>
26968
27402
  // 两个 UI 共用的表单规则:宿主面板(src/app.js)与独立页(src/web-ui.js)。