dsh-metaphysics 0.62.0 → 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 (48) hide show
  1. package/CHANGELOG.md +55 -3
  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 +71 -67
  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 +25 -8
  29. package/engine/geo.js +2 -1
  30. package/engine/hepan.js +4 -3
  31. package/engine/index.js +208 -72
  32. package/engine/liuyao.js +24 -5
  33. package/engine/mbti.js +7 -1
  34. package/engine/meihua.js +21 -7
  35. package/engine/name.js +23 -12
  36. package/engine/numerology.js +3 -3
  37. package/engine/qimen.js +9 -1
  38. package/engine/random.js +17 -3
  39. package/engine/rectify.js +4 -3
  40. package/engine/star.js +3 -3
  41. package/engine/tarot.js +22 -8
  42. package/engine/trace.js +2 -1
  43. package/engine/trend.js +10 -3
  44. package/engine/xiaoliuren.js +2 -1
  45. package/engine/ziwei.js +4 -3
  46. package/index.js +367 -42
  47. package/package.json +1 -1
  48. package//345/221/275/347/220/206/346/216/222/347/233/230.html +611 -152
package/engine/liuyao.js CHANGED
@@ -3,6 +3,7 @@ import { STEMS, BRANCHES, toJDN, ganzhiName, dayGanzhiIndex, sexagenaryIndex, xu
3
3
  import { jingText } from './yijing-text.js';
4
4
  import { fail } from './fail.js';
5
5
  import { assertObject } from './validate.js';
6
+ import { randomOf, isSeed } from './random.js';
6
7
 
7
8
  // 八卦三位(自下而上,1 = 阳)
8
9
  const TRIGRAM_BITS = {
@@ -335,10 +336,8 @@ const sideOf = (position) => (position <= 3 ? 'inner' : 'outer');
335
336
  * @param {{direction?:string, gender?:string}} [opts]
336
337
  */
337
338
  export function yongshenOf(result, opts = {}) {
338
- // opts 允许省略或传 null(都按默认口径),但显式给了别的东西就必须是对象:
339
+ opts = liuyaoOpts(opts, 'yongshenOf 的 opts', '{ direction?, gender? }');
339
340
  // 以前 yongshenOf(r, null) 会一路走到读 opts.direction,抛裸 TypeError
340
- if (opts === null || opts === undefined) opts = {};
341
- assertObject(opts, 'yongshenOf 的 opts', '{ direction?, gender? };省略或 null 按默认口径');
342
341
  const r = result && typeof result === 'object' ? result : {};
343
342
  const ben = r.ben && typeof r.ben === 'object' ? r.ben : {};
344
343
  const lines = Array.isArray(ben.lines) ? ben.lines : [];
@@ -490,6 +489,14 @@ function isMoving(v) {
490
489
  */
491
490
  export function buildLiuyao(input = {}) {
492
491
  if (!input || typeof input !== 'object') throw fail('E_INPUT', '六爻起卦参数不能为空:至少给 lines 或 seed');
492
+ // seed 以前只写在文档里:直接 buildLiuyao({ seed }) 仍走 Math.random,
493
+ // 同一个 seed 摇不出同一卦,句柄却按 seed 算了身份。这里与工具层同一把尺。
494
+ if (input.seed !== undefined && input.seed !== null && input.seed !== '' && !isSeed(input.seed)) {
495
+ throw fail('E_INPUT', `seed 必须是数字,收到「${String(input.seed).slice(0, 24)}」`);
496
+ }
497
+ if (input.random !== undefined && input.random !== null && typeof input.random !== 'function') {
498
+ throw fail('E_INPUT', `random 必须是函数,收到 ${typeof input.random}`);
499
+ }
493
500
  // lines 给了但不是数组("7,8,9,6,7,8" / 789678 / {…})以前等同于「没给」:
494
501
  // 用户以为摇的是自己记下的那一卦,实际拿到一张随机盘,而且看不出来。
495
502
  if (input.lines !== undefined && input.lines !== null && !Array.isArray(input.lines)) {
@@ -501,7 +508,8 @@ export function buildLiuyao(input = {}) {
501
508
  if (given && given.length !== 6) {
502
509
  throw fail('E_LINES', `lines 必须是 6 个爻值(自下而上),收到 ${given.length} 个`);
503
510
  }
504
- const rawLines = given ? given.slice() : tossLines(input.random);
511
+ const rand = typeof input.random === 'function' ? input.random : randomOf(input.seed);
512
+ const rawLines = given ? given.slice() : tossLines(rand);
505
513
 
506
514
  const invalid = rawLines.filter((v) => ![6, 7, 8, 9].includes(v));
507
515
  if (invalid.length) throw fail('E_LINES', `爻值只能是 6/7/8/9,收到 ${JSON.stringify(rawLines)}`);
@@ -513,7 +521,7 @@ export function buildLiuyao(input = {}) {
513
521
  const bian = bianBits ? hexagramFromBits(bianBits) : null;
514
522
 
515
523
  const dayGanZhi = input.dayGanZhi ?? (() => {
516
- const now = new Date();
524
+ const now = new Date(); // allow-clock: 未传日辰时按起卦当天取日干起六神;asOf 可钉死
517
525
  const jdn = toJDN(now.getFullYear(), now.getMonth() + 1, now.getDate());
518
526
  const g = ganzhiName(dayGanzhiIndex(jdn));
519
527
  return { stem: g.stem, branch: g.branch, name: g.name };
@@ -662,6 +670,12 @@ export function dayRelationOf(element, dayElement) {
662
670
  return 'unknown';
663
671
  }
664
672
 
673
+ /** opts 省略或传 null 都按默认口径;显式给了别的类型则当场报 E_INPUT。 */
674
+ function liuyaoOpts(opts, who, shape) {
675
+ if (opts === null || opts === undefined) return {};
676
+ return assertObject(opts, who, `${shape};省略或 null 按默认口径`);
677
+ }
678
+
665
679
  /**
666
680
  * 用神旺衰:把月建、日辰两股外部力量落到用神那几爻上。
667
681
  * 月建按月支、日辰按日支,各出自己的五行,再与本爻五行论生克;
@@ -671,6 +685,7 @@ export function dayRelationOf(element, dayElement) {
671
685
  * @param {{monthBranch?: string, dayBranch?: string}} opts
672
686
  */
673
687
  export function yongshenStrength(result, useGod, opts = {}) {
688
+ opts = liuyaoOpts(opts, 'yongshenStrength 的 opts', '{ monthBranch?, dayBranch? }');
674
689
  if (!useGod || !Array.isArray(useGod.found) || !useGod.found.length) return null;
675
690
  const monthBranch = opts.monthBranch || null;
676
691
  const dayBranch = opts.dayBranch || (result && result.dayGanZhi ? result.dayGanZhi.branch : null);
@@ -747,6 +762,7 @@ export const FUSHEN_VERDICTS = ['可出', '有助有压', '受压难出', '未
747
762
  * @param {{monthBranch?: string, dayBranch?: string}} opts
748
763
  */
749
764
  export function fushenAccess(result, useGod, opts = {}) {
765
+ opts = liuyaoOpts(opts, 'fushenAccess 的 opts', '{ monthBranch?, dayBranch? }');
750
766
  if (!useGod || !Array.isArray(useGod.fushen) || !useGod.fushen.length) return null;
751
767
  const monthBranch = opts.monthBranch || null;
752
768
  const dayBranch = opts.dayBranch || (result && result.dayGanZhi ? result.dayGanZhi.branch : null);
@@ -822,6 +838,7 @@ export function fushenAccess(result, useGod, opts = {}) {
822
838
  // 与用神旺衰同一套判定(月令五态、日辰五关系、三种冲法),区别只在不挑用神:
823
839
  // 六爻每一爻都给一次,世应、动爻、旬空另标,看得出整张卦的气压在哪一头。
824
840
  export function hexagramStrength(result, opts = {}) {
841
+ opts = liuyaoOpts(opts, 'hexagramStrength 的 opts', '{ monthBranch?, dayBranch? }');
825
842
  const rows = (result && result.ben && Array.isArray(result.ben.lines))
826
843
  ? result.ben.lines
827
844
  : (result && Array.isArray(result.lines) ? result.lines : []);
@@ -910,6 +927,7 @@ const TUI_VERDICT = { 旺: '退得缓', 相: '退得缓', 休: '退得实', 囚:
910
927
  * @param {{monthBranch?: string, dayBranch?: string}} opts
911
928
  */
912
929
  export function jinTuiPower(result, opts = {}) {
930
+ opts = liuyaoOpts(opts, 'jinTuiPower 的 opts', '{ monthBranch?, dayBranch? }');
913
931
  const items = result && result.body && Array.isArray(result.body.jinTui) ? result.body.jinTui : [];
914
932
  if (!items.length) return null;
915
933
  const rows = result && result.ben && Array.isArray(result.ben.lines) ? result.ben.lines : [];
@@ -983,6 +1001,7 @@ const TRIGGER_ORDER = ['逢值', '逢冲', '逢合', '出空填实', '冲空'];
983
1001
  * @param {{monthBranch?: string}} opts
984
1002
  */
985
1003
  export function yingqiWindow(result, useGod, opts = {}) {
1004
+ opts = liuyaoOpts(opts, 'yingqiWindow 的 opts', '{ monthBranch? }');
986
1005
  if (!useGod || !useGod.liuqin) return null;
987
1006
  const found = Array.isArray(useGod.found) ? useGod.found : [];
988
1007
  const fushen = Array.isArray(useGod.fushen) ? useGod.fushen : [];
package/engine/mbti.js CHANGED
@@ -352,6 +352,7 @@ function strengthOf(pct, answered) {
352
352
  * 维度净分 > 0 取左字母,< 0 取右字母,= 0 按维度表的 tie 取(并标「临界」)。
353
353
  */
354
354
  export function buildMbti(input = {}) {
355
+ input = input && typeof input === 'object' ? input : {};
355
356
  const answers = normalizeAnswers(input.answers);
356
357
  const answeredAll = answers.filter((v) => v !== null).length;
357
358
  if (answeredAll < 12) {
@@ -384,8 +385,11 @@ export function buildMbti(input = {}) {
384
385
  picked,
385
386
  strength: strengthOf(pct, answered),
386
387
  tie: sum === 0,
388
+ unanswered: answered === 0,
387
389
  };
388
390
  });
391
+ // 整维未答时 letter 只是 tie 占位;把它单独列出来,摘要与面板才不会当成「测出来了」
392
+ const unanswered = dims.filter((d) => d.unanswered).map((d) => d.key);
389
393
  const type = dims.map((d) => d.picked).join('');
390
394
  const profile = TYPES[type];
391
395
  if (!profile) throw fail('E_SYSTEM', `内部错误:类型 ${type} 没有对应文案`);
@@ -412,6 +416,8 @@ export function buildMbti(input = {}) {
412
416
  answered: answeredAll,
413
417
  skipped: QUESTIONS.length - answeredAll,
414
418
  total: QUESTIONS.length,
415
- note: '四维各 6 题、李克特 1–5 计分,按维度净分取字母(平手时取 I / N / F / P 一侧并标「临界」)。这是 MBTI 风格的问卷测算,不是官方量表;类型只作自我参照,不是能力评价,也不用它给人下结论。',
419
+ unanswered,
420
+ note: '四维各 6 题、李克特 1–5 计分,按维度净分取字母(平手时取 I / N / F / P 一侧并标「临界」)。这是 MBTI 风格的问卷测算,不是官方量表;类型只作自我参照,不是能力评价,也不用它给人下结论。'
421
+ + (unanswered.length ? ` 本轮 ${unanswered.join('、')} 维度一题未答,该维按平手取字母,只作占位,参考价值有限。` : ''),
416
422
  };
417
423
  }
package/engine/meihua.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { BRANCHES, fromJDN, toJDN } from './calendar.js';
3
3
  import { solarToLunar } from './lunar.js';
4
4
  import { fail } from './fail.js';
5
+ import { bool } from './bool.js';
5
6
  import { parseDateTime } from './datetime.js';
6
7
  import { assertShifted } from './validate.js';
7
8
  import { hexagramFromBits } from './liuyao.js';
@@ -55,16 +56,29 @@ export function buildMeihua(input = {}) {
55
56
  let source;
56
57
 
57
58
  if (mode === 'number') {
58
- const a = Number(input.upper);
59
- const b = Number(input.lower);
60
- if (!Number.isFinite(a) || !Number.isFinite(b)) throw fail('E_INPUT', '数字起卦需要两个数字(upper / lower),例如 upper=3、lower=5');
59
+ // 只认真正的数字:Number(null) / Number(false) / Number([]) 都是 0,
60
+ // 以前缺参或传错类型会静默起成「坤为地」,盘面看着正常却完全不是用户给的字数。
61
+ const a = input.upper;
62
+ const b = input.lower;
63
+ // 小数以前只过 Number.isFinite,mod8(3.5) 得到 4.5,bitsOf 查不到卦再在深处抛
64
+ // 「bitsOf is not a function」——参数错却报成内部错误。数字起卦只认整数。
65
+ if (typeof a !== 'number' || typeof b !== 'number' || !Number.isInteger(a) || !Number.isInteger(b)) {
66
+ throw fail('E_INPUT', '数字起卦需要两个整数(upper / lower),例如 upper=3、lower=5');
67
+ }
61
68
  upperNum = mod8(a);
62
69
  lowerNum = mod8(b);
63
70
  if (input.changing !== undefined && input.changing !== null && input.changing !== '') {
64
- // 以前 99 会被 mod 6 折成 3:用户指定动爻却得到另一爻,结果里看不出来
65
- const ch = Number(input.changing);
71
+ // 以前 99 会被 mod 6 折成 3:用户指定动爻却得到另一爻,结果里看不出来。
72
+ // Number(true)=1、Number([3])=3 也会静默通过,故只收数字或十进制数字串。
73
+ const raw = input.changing;
74
+ const ch = typeof raw === 'number'
75
+ ? raw
76
+ : typeof raw === 'string' && /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/.test(raw.trim())
77
+ ? Number(raw.trim())
78
+ : NaN;
66
79
  if (!Number.isInteger(ch) || ch < 1 || ch > 6) {
67
- throw fail('E_INPUT', `动爻 changing 必须是 1–6 的整数,收到「${input.changing}」`);
80
+ const shown = typeof raw === 'string' ? raw : JSON.stringify(raw);
81
+ throw fail('E_INPUT', `动爻 changing 必须是 1–6 的整数,收到「${shown}」`);
68
82
  }
69
83
  moving = ch;
70
84
  } else {
@@ -74,7 +88,7 @@ export function buildMeihua(input = {}) {
74
88
  } else {
75
89
  const dt = parseDateTime(input.datetime);
76
90
  // 晚子时换日与八字/六壬/奇门同口径:23:00–23:59 归次日,年月日电数才落在同一子时
77
- const lateZi = input.lateZi !== false;
91
+ const lateZi = bool(input.lateZi, true);
78
92
  let sy = dt.year;
79
93
  let sm = dt.month;
80
94
  let sd = dt.day;
package/engine/name.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // 五格按康熙字典繁体笔画计(见 name-data.js 的口径说明),81 数理取通行本;
5
5
  // 音韵只做机械检查(同调、相邻同声母/同韵母、叠字),不做语义谐音判断。
6
6
  import { buildBazi } from './bazi.js';
7
+ import { bool } from './bool.js';
7
8
  import { assertCivil, assertObject } from './validate.js';
8
9
  import { fail } from './fail.js';
9
10
  import {
@@ -267,19 +268,23 @@ const WX_REL = (a, b) => (a === b ? '同' : SHENG[a] === b ? '生' : KE[a] === b
267
268
 
268
269
  /** 数理超过 81 按传统「减 80 循环」取(如 104 → 24)。 */
269
270
  export function shuliOf(n) {
270
- let x = n;
271
- while (x > 81) x -= 80;
271
+ // 用取模代替 while 递减:strokeOverrides 允许用户给任意正整数,
272
+ // 以前给 1e12 会在这里空转一百多亿次,整次排盘卡死。
273
+ const x = n > 81 ? ((n - 1) % 80) + 1 : n;
272
274
  if (!(x >= 1)) return null;
273
275
  const row = SHULI_81[x];
274
276
  return row ? { num: x, level: row[0], name: row[1] } : null;
275
277
  }
276
278
 
277
- /** 合并两处笔画覆盖:surnameStrokes(按姓给的)+ strokeOverrides(逐字覆盖),后者优先。 */
279
+ /** 合并两处笔画覆盖:surnameStrokes(按姓给的)+ strokeOverrides(逐字覆盖),后者优先。
280
+ * 笔画值允许数字串(`{"秋":"9"}`):模型经 JSON 传参时常见,只在入口归一还不够——
281
+ * 这里再兜一层,免得「用户给了笔画却被静默按字库算」这种错法又冒出来。 */
278
282
  function overridesOf(input) {
279
283
  const out = {};
280
284
  for (const src of [input && input.surnameStrokes, input && input.strokeOverrides]) {
281
285
  if (!src || typeof src !== 'object') continue;
282
- for (const [k, v] of Object.entries(src)) {
286
+ for (const [k, raw] of Object.entries(src)) {
287
+ const v = typeof raw === 'string' ? Number(raw.trim()) : raw;
283
288
  if (Number.isInteger(v) && v > 0) out[k] = v;
284
289
  }
285
290
  }
@@ -507,12 +512,14 @@ export function assessName(input) {
507
512
  assertCivil(input, 'birth');
508
513
  const r = buildBazi({
509
514
  year: input.year, month: input.month, day: input.day,
510
- hour: Number.isInteger(input.hour) ? input.hour : 12,
515
+ // 只给日期时与 datetime:'YYYY-MM-DD' 同一口径(00:00 占位);引擎摘要会标明缺时刻,
516
+ // 以前这里悄悄补 12:00,同一个生日走 datetime 和 year/month/day 会得到两档旺衰。
517
+ hour: Number.isInteger(input.hour) ? input.hour : 0,
511
518
  minute: Number.isInteger(input.minute) ? input.minute : 0,
512
519
  gender: input.gender || 'male',
513
- lateZi: input.lateZi !== false,
514
- trueSolar: input.trueSolar !== false,
515
- dst: input.dst !== false,
520
+ lateZi: bool(input.lateZi, true),
521
+ trueSolar: bool(input.trueSolar, true),
522
+ dst: bool(input.dst, true),
516
523
  longitude: input.longitude, address: input.address, geo: input.geo,
517
524
  });
518
525
  strength = { level: r.strength.level, score: r.strength.score, dayElement: r.pillars.day.stemElement };
@@ -608,12 +615,13 @@ export function generateNames(input) {
608
615
  const limit = Math.max(1, Math.min(20, Number.isInteger(input.limit) ? input.limit : 10));
609
616
  const r = buildBazi({
610
617
  year: input.year, month: input.month, day: input.day,
611
- hour: Number.isInteger(input.hour) ? input.hour : 12,
618
+ // 与 assessName 同一口径:缺时刻按 00:00 占位,由引擎摘要统一提示
619
+ hour: Number.isInteger(input.hour) ? input.hour : 0,
612
620
  minute: Number.isInteger(input.minute) ? input.minute : 0,
613
621
  gender,
614
- lateZi: input.lateZi !== false,
615
- trueSolar: input.trueSolar !== false,
616
- dst: input.dst !== false,
622
+ lateZi: bool(input.lateZi, true),
623
+ trueSolar: bool(input.trueSolar, true),
624
+ dst: bool(input.dst, true),
617
625
  longitude: input.longitude, address: input.address, geo: input.geo,
618
626
  });
619
627
  const strength = { level: r.strength.level, score: r.strength.score, dayElement: r.pillars.day.stemElement };
@@ -726,6 +734,9 @@ export function buildName(input) {
726
734
  if (!input || typeof input !== 'object') {
727
735
  throw fail('E_INPUT', '起名参数不能为空:评名要 surname / givenName,起名要 surname 与性别/生日');
728
736
  }
737
+ if (input.action != null && input.action !== 'assess' && input.action !== 'generate') {
738
+ throw fail('E_INPUT', `起名 action 只能是 assess / generate,收到「${String(input.action).slice(0, 24)}」`);
739
+ }
729
740
  if (input.action === 'generate') return generateNames(input);
730
741
  return assessName(input);
731
742
  }
@@ -1,6 +1,6 @@
1
1
  // 生命数字(西方数字学):生命路径数 / 生日数 / 态度数,11、22、33 主数保留。
2
2
  // 纯本地确定性;与八字/紫微是两套不可互证的体系,输出里明确提示。
3
- import { daysInMonth } from './validate.js';
3
+ import { MAX_YEAR, MIN_YEAR, daysInMonth } from './validate.js';
4
4
  import { fail } from './fail.js';
5
5
 
6
6
  /** 1–9 与主数 11/22/33 的数义(白话提炼)。 */
@@ -38,8 +38,8 @@ const digitSum = (n) => String(Math.abs(Math.trunc(n))).split('').reduce((s, c)
38
38
  export function buildNumerology(input) {
39
39
  const { year, month, day } = input || {};
40
40
  if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)
41
- || year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
42
- throw fail('E_INPUT', '生命数字需要真实存在的公历出生日期(year 1900–2100、month、day)——请向用户询问,勿猜');
41
+ || year < MIN_YEAR || year > MAX_YEAR || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
42
+ throw fail('E_INPUT', `生命数字需要真实存在的公历出生日期(year ${MIN_YEAR}–${MAX_YEAR}、month、day)——请向用户询问,勿猜`);
43
43
  }
44
44
  const lifePath = reduceNumber(digitSum(year) + digitSum(month) + digitSum(day));
45
45
  const birthday = reduceNumber(day);
package/engine/qimen.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { termInstantJd, termWindow, jdToParts, partsToJd, formatJd, ganzhiName, sexagenaryIndex, STEMS, BRANCHES } from './calendar.js';
6
6
  import { solarToLunar } from './lunar.js';
7
7
  import { assertCivil, assertShifted } from './validate.js';
8
+ import { fail } from './fail.js';
8
9
  import { ganyingOf, LIUYI_RU_MU } from './qimen-ganying.js';
9
10
 
10
11
  const TIAN_GAN = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'];
@@ -44,7 +45,11 @@ const indexOfGz = (gz) => {
44
45
  };
45
46
 
46
47
  /** 由公历时刻构建快照(本地民用时间轴) */
47
- export function buildSnapshot({ year, month, day, hour, minute, lateZiHourMode = 'same-day' }) {
48
+ export function buildSnapshot(input) {
49
+ if (!input || typeof input !== 'object') {
50
+ throw fail('E_INPUT', '奇门排盘参数不能为空:至少给 year / month / day / hour');
51
+ }
52
+ const { year, month, day, hour, minute, lateZiHourMode = 'same-day' } = input;
48
53
  assertCivil({ year, month, day, hour, minute });
49
54
  const jd = partsToJd({ year, month, day, hour, minute });
50
55
 
@@ -450,6 +455,9 @@ const findGong = (obj, val) =>
450
455
 
451
456
  /** 排盘主函数 */
452
457
  export function buildQimen(input) {
458
+ if (!input || typeof input !== 'object') {
459
+ throw fail('E_INPUT', '奇门排盘参数不能为空:至少给 year / month / day / hour');
460
+ }
453
461
  // snapshot 是内部快照路径(已由 buildSnapshot 校验过),不必重复校验
454
462
  if (!input.snapshot) assertCivil(input);
455
463
  const snapshot = input.snapshot || buildSnapshot(input);
package/engine/random.js CHANGED
@@ -22,20 +22,34 @@ export function mulberry32(seed) {
22
22
  };
23
23
  }
24
24
 
25
+ // 与宿主 / bool.js 同一把尺:只认十进制(可带指数、尾点)。Number() 会把
26
+ // "0x10"、false、[] 也变成有效数字,于是直接调引擎能排出盘、走工具层却被拒——
27
+ // 同一份输入两种命运,正是句柄与复现最容易分叉的地方。
28
+ const NUMERIC_TEXT = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
29
+
30
+ const seedNumber = (seed) => {
31
+ if (typeof seed === 'number') return Number.isFinite(seed) ? seed : NaN;
32
+ if (typeof seed === 'string') {
33
+ const s = seed.trim();
34
+ if (s !== '' && NUMERIC_TEXT.test(s)) return Number(s);
35
+ }
36
+ return NaN;
37
+ };
38
+
25
39
  /**
26
40
  * 从参数里取随机源:
27
41
  * - 没给 seed:沿用 Math.random(保持"真随机"的手感)
28
- * - 给了数字或数字字符串:mulberry32(seed),可复现
42
+ * - 给了数字或十进制数字串:mulberry32(seed),可复现
29
43
  * - 给了别的东西:调用方应当先报错,这里兜底回 Math.random
30
44
  */
31
45
  export function randomOf(seed) {
32
46
  if (seed === undefined || seed === null || seed === '') return Math.random;
33
- const n = Number(seed);
47
+ const n = seedNumber(seed);
34
48
  return Number.isFinite(n) ? mulberry32(n) : Math.random;
35
49
  }
36
50
 
37
51
  /** 是否是一个可以接受的 seed */
38
52
  export function isSeed(v) {
39
53
  if (v === undefined || v === null || v === '') return true;
40
- return Number.isFinite(Number(v));
54
+ return Number.isFinite(seedNumber(v));
41
55
  }
package/engine/rectify.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // 2. 只有时柱(以及跨 23:00 时的日柱)会变,年/月柱通常不变,所以候选之间的差异集中在晚运、子女、事业后半段;
7
7
  // 3. 打分规则是显式、固定的(见 matchEvent),不是拟合;换一套规则排序可能变。
8
8
  import { buildBazi } from './bazi.js';
9
+ import { bool } from './bool.js';
9
10
 
10
11
  const KINDS = ['career', 'wealth', 'love', 'move', 'health', 'other'];
11
12
  const HOURS = [0, 1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 23];
@@ -120,9 +121,9 @@ export function rectifyBirth(opts) {
120
121
  hour,
121
122
  minute: 0,
122
123
  gender,
123
- lateZi: opts.lateZi !== false,
124
- trueSolar: opts.trueSolar !== false,
125
- dst: opts.dst !== false,
124
+ lateZi: bool(opts.lateZi, true),
125
+ trueSolar: bool(opts.trueSolar, true),
126
+ dst: bool(opts.dst, true),
126
127
  address: typeof opts.address === 'string' ? opts.address : undefined,
127
128
  longitude: typeof opts.longitude === 'number' ? opts.longitude : undefined,
128
129
  geo: opts.geo && typeof opts.geo === 'object' ? opts.geo : undefined,
package/engine/star.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // 星座档案 + 当日娱乐向基调:纯本地确定性(同一天同一星座结果永远一致)。
2
2
  // 星座按太阳过宫的通行日期区间(公历);当日基调由日期哈希生成,不是随机数。
3
- import { daysInMonth } from './validate.js';
3
+ import { MAX_YEAR, MIN_YEAR, daysInMonth } from './validate.js';
4
4
  import { fail } from './fail.js';
5
5
 
6
6
  /** 12 星座(区间含起止日;摩羯跨年由两端分别匹配)。 */
@@ -51,8 +51,8 @@ const TONE_TEXT = {
51
51
  export function buildStar(input) {
52
52
  const { year, month, day } = input || {};
53
53
  if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)
54
- || year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
55
- throw fail('E_INPUT', '星座需要真实存在的公历日期(year 1900–2100、month、day)——请向用户询问');
54
+ || year < MIN_YEAR || year > MAX_YEAR || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
55
+ throw fail('E_INPUT', `星座需要真实存在的公历日期(year ${MIN_YEAR}–${MAX_YEAR}、month、day)——请向用户询问`);
56
56
  }
57
57
  const sign = signOfDate(month, day);
58
58
  if (!sign) throw fail('E_INPUT', `无法识别星座:${year}-${month}-${day}`);
package/engine/tarot.js CHANGED
@@ -277,12 +277,17 @@ export function tarotDeck() {
277
277
  // 名称 → 牌面数据(解读时按名字回查,保证确定性、且不依赖调用方是否带全字段)
278
278
  const BY_NAME = new Map(tarotDeck().map((c) => [c.name, c]));
279
279
 
280
+ // 外部手输/旧数据常见的另一种译名。只做显式别名,不做模糊匹配:
281
+ // 拼错的牌名不能被猜成另一张,否则解读会披着别的牌义,错得看不出来。
282
+ const CARD_ALIASES = { 愚人: '愚者' };
283
+ const cardMeta = (name) => BY_NAME.get(CARD_ALIASES[name] || name) || null;
284
+
280
285
  const splitKeywords = (s) => String(s || '').split(/\s+/).filter(Boolean);
281
286
 
282
287
  /** 抽牌时刻(只做记录,不影响牌面) */
283
288
  function stamp(at) {
284
289
  if (typeof at === 'string' && at.trim()) return at.trim().replace('T', ' ').slice(0, 16);
285
- const d = new Date();
290
+ const d = new Date(); // allow-clock: 未传 asOf 时记录抽牌时刻;牌面由 seed 固定
286
291
  const p = (x) => String(x).padStart(2, '0');
287
292
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
288
293
  }
@@ -306,12 +311,17 @@ export function tarotReading(input = {}) {
306
311
  if (!n) return ['没有抽到牌面,无法解读。'];
307
312
 
308
313
  const names = list.map((c) => c.name).join(' / ');
314
+ // 牌库外的牌名不能悄悄读成「空牌义」:开场点名是哪几张,逐张处退回正/逆位泛义。
315
+ const unknown = list.map((c) => c.name).filter((name) => !cardMeta(name));
316
+ const unknownNote = unknown.length
317
+ ? `其中「${unknown.join('」「')}」不在韦特 78 张牌库里,这几张只按你给的位置与正逆位泛读,不补牌面、元素与占星对应。`
318
+ : '';
309
319
  const paras = [];
310
320
 
311
321
  // ① 开场:所问 + 象征系统声明
312
322
  paras.push(q
313
- ? `你问的是「${q}」,抽到的是 ${n} 张牌阵(${names})。塔罗是一套象征系统而不是判决:不同流派对牌义、编号与逆位的取舍略有差异,这里按 RWS(韦特)牌名与黄金黎明的元素对应来读,请把下面的话当作镜子,对照自己的处境取用。`
314
- : `这次没有具体提问,那就先看牌面本身在说什么。牌阵 ${n} 张(${names})。塔罗是象征系统,牌义随流派略有差异,这里按 RWS(韦特)与黄金黎明的常见对应来读,读到合用的部分就好。`);
323
+ ? `你问的是「${q}」,抽到的是 ${n} 张牌阵(${names})。塔罗是一套象征系统而不是判决:不同流派对牌义、编号与逆位的取舍略有差异,这里按 RWS(韦特)牌名与黄金黎明的元素对应来读,请把下面的话当作镜子,对照自己的处境取用。${unknownNote}`
324
+ : `这次没有具体提问,那就先看牌面本身在说什么。牌阵 ${n} 张(${names})。塔罗是象征系统,牌义随流派略有差异,这里按 RWS(韦特)与黄金黎明的常见对应来读,读到合用的部分就好。${unknownNote}`);
315
325
 
316
326
  // ② 逐张:位义 + 牌名/正逆 + 关键词 + 一句与位义结合的解读
317
327
  // 1-3 张时每张一段;4 张以上按前后两段分组(前段取 ceil(n/2) 张),
@@ -321,8 +331,10 @@ export function tarotReading(input = {}) {
321
331
  for (const group of groups) {
322
332
  const lines = group.map((i) => {
323
333
  const c = list[i];
324
- const meta = BY_NAME.get(c.name) || {};
325
- const kws = (c.keywords && c.keywords.length ? c.keywords : splitKeywords(c.upright ? meta.up : meta.down)).join('、');
334
+ const meta = cardMeta(c.name) || {};
335
+ // 牌库外又没有关键词时,退回正/逆位的泛义,别留下「正位:。」的空句
336
+ const kws = (c.keywords && c.keywords.length ? c.keywords : splitKeywords(c.upright ? meta.up : meta.down)).join('、')
337
+ || (c.upright === false ? '逆位泛义(阻滞、内转或用力过头)' : '正位泛义(推进、显化、顺势)');
326
338
  const ori = c.upright === false ? '逆位' : '正位';
327
339
  const oriNote = c.upright === false
328
340
  ? '逆位并不等于凶,更像这股力量还没顺过来,或用力过了头'
@@ -347,7 +359,7 @@ export function tarotReading(input = {}) {
347
359
  const upCount = n - revCount;
348
360
  const elementCount = {};
349
361
  for (const c of list) {
350
- const e = (BY_NAME.get(c.name) || {}).element || c.element;
362
+ const e = (cardMeta(c.name) || {}).element || c.element;
351
363
  if (e) elementCount[e] = (elementCount[e] || 0) + 1;
352
364
  }
353
365
  const present = ELEMENT_ORDER.filter((e) => elementCount[e]);
@@ -376,7 +388,7 @@ export function tarotReading(input = {}) {
376
388
  const majorCard = list.find((c) => c.arcana === '大阿卡纳');
377
389
  const revCard = list.find((c) => c.upright === false);
378
390
  const key = majorCard || revCard || list[0];
379
- const keyMeta = BY_NAME.get(key.name) || {};
391
+ const keyMeta = cardMeta(key.name) || {};
380
392
  const keyReason = majorCard
381
393
  ? '它出现的地方,往往就是这件事真正被牵动的层级'
382
394
  : revCard
@@ -449,7 +461,9 @@ export function drawTarot(input = {}) {
449
461
  keywords: splitKeywords(upright ? c.up : c.down),
450
462
  };
451
463
  });
452
- const q = (question || '').trim();
464
+ // 直接调引擎时 question 可能是数字(工具层 schema 的 string 不做强转),
465
+ // 以前 (123 || '').trim() 会抛裸 TypeError,整张牌只剩一句内部错误。
466
+ const q = String(question == null ? '' : question).trim();
453
467
  return {
454
468
  system: 'tarot',
455
469
  question: q || null,
package/engine/trace.js CHANGED
@@ -9,6 +9,7 @@ import { PATTERN_TONE } from './ziwei.js';
9
9
  import { yongshenOf, yongshenStrength, fushenAccess } from './liuyao.js';
10
10
  import { qimenYongshen } from './qimen-interpret.js';
11
11
  import { GAN_JI_GONG } from './daliuren.js';
12
+ import { bool } from './bool.js';
12
13
 
13
14
  const SYSTEM_LABEL = { liuyao: '六爻', bazi: '八字', ziwei: '紫微', qimen: '奇门', daliuren: '大六壬' };
14
15
 
@@ -429,7 +430,7 @@ export function buildTrace(result, opts = {}) {
429
430
  if (result.system === 'liuyao') {
430
431
  let view = result;
431
432
  // 面板改了「询问方向」但不想重摇一卦:只按新方向重算用神相关三层
432
- if (opts.refresh === true) {
433
+ if (bool(opts.refresh)) {
433
434
  try {
434
435
  const ug = yongshenOf(result, { direction: opts.direction, gender: opts.gender });
435
436
  const monthBranch = (result.hexStrength && result.hexStrength.month && result.hexStrength.month.branch)
package/engine/trend.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // 口径:这是一套**简化启发式**——十神计分表固定、正负号随日主旺衰方向翻转,
5
5
  // 只用于看形态与相对起伏;分高不等于真实吉凶,旺衰落在换档边界时连排序都不给。
6
6
  import { buildBazi, HIDDEN_STEMS, tenGod } from './bazi.js';
7
+ import { bool } from './bool.js';
7
8
  import { ganzhiName, sexagenaryIndex, STEMS, BRANCHES } from './calendar.js';
8
9
  import { fail } from './fail.js';
9
10
 
@@ -85,6 +86,9 @@ function buildSegments(list, direction, birthYear, toAge) {
85
86
  * address?:string,longitude?:number,geo?:object}} input
86
87
  */
87
88
  export function buildTrend(input) {
89
+ if (!input || typeof input !== 'object') {
90
+ throw fail('E_INPUT', '人生 K 线需要参数对象:至少给 datetime 与 gender(大运顺逆由性别决定)');
91
+ }
88
92
  const gender = input.gender;
89
93
  if (gender !== 'male' && gender !== 'female') {
90
94
  throw fail('E_INPUT', '人生 K 线需要性别:大运顺逆由性别决定,不可缺省');
@@ -104,10 +108,13 @@ export function buildTrend(input) {
104
108
  day: input.day,
105
109
  hour: Number.isInteger(input.hour) ? input.hour : 12,
106
110
  minute: Number.isInteger(input.minute) ? input.minute : 0,
111
+ // 时辰未知必须一路传到 buildBazi:它在内部统一按正午占位并收回时柱。
112
+ // 少了这一个键,摘要写着「按正午暂算」,旺衰却按 00:00 算成了另一档。
113
+ hourUnknown: bool(input.hourUnknown),
107
114
  gender,
108
- lateZi: input.lateZi !== false,
109
- trueSolar: input.trueSolar !== false,
110
- dst: input.dst !== false,
115
+ lateZi: bool(input.lateZi, true),
116
+ trueSolar: bool(input.trueSolar, true),
117
+ dst: bool(input.dst, true),
111
118
  address: typeof input.address === 'string' ? input.address : undefined,
112
119
  longitude: typeof input.longitude === 'number' ? input.longitude : undefined,
113
120
  geo: input.geo && typeof input.geo === 'object' ? input.geo : undefined,
@@ -1,6 +1,7 @@
1
1
  // 小六壬(诸葛马前课):月/日/时(或报三个数)掐指落宫,六宫循环。
2
2
  // 口径:通行掌诀;断语为白话提炼(非原文照录)。轻量快占,属民俗参考。
3
3
  import { solarToLunar } from './lunar.js';
4
+ import { bool } from './bool.js';
4
5
  import { hourToBranch } from './almanac.js';
5
6
  import { BRANCHES, fromJDN, toJDN } from './calendar.js';
6
7
  import { parseYmd } from './datetime.js';
@@ -49,7 +50,7 @@ export function buildXiaoliuren(input) {
49
50
  let y = mt.year;
50
51
  let mo = mt.month;
51
52
  let dy = mt.day;
52
- const lateZi = input.lateZi !== false;
53
+ const lateZi = bool(input.lateZi, true);
53
54
  if (lateZi && input.hour >= 23) {
54
55
  const next = assertShifted(fromJDN(toJDN(y, mo, dy) + 1), '小六壬时间起课');
55
56
  y = next.year;
package/engine/ziwei.js CHANGED
@@ -8,6 +8,7 @@ import { nayinOf } from './nayin.js';
8
8
  import { resolveAddress } from './geo.js';
9
9
  import { placeZuoyao, zuoyaoVerseBlock, ZUOYAO_SKIPPED } from './ziwei-zuoyao.js';
10
10
  import { SHA_STAR } from './ziwei-words.js';
11
+ import { bool } from './bool.js';
11
12
 
12
13
  const fix = (i) => ((i % 12) + 12) % 12;
13
14
 
@@ -397,7 +398,7 @@ export function buildZiwei(input) {
397
398
  assertCivil(input);
398
399
  const schools = ziweiSchoolsOf(input);
399
400
  const { year: cy, month: cm, day: cd, hour: ch = 0, minute: cmin = 0, gender = 'male' } = input;
400
- const useTrueSolar = input.trueSolar !== false;
401
+ const useTrueSolar = bool(input.trueSolar, true);
401
402
  const geo = input.geo || (input.address ? resolveAddress(input.address) : null);
402
403
  const longitude = typeof input.longitude === 'number'
403
404
  ? input.longitude
@@ -671,7 +672,7 @@ export function buildZiwei(input) {
671
672
  // 直接读挂钟的话,同一组参数(同一个句柄)跨年会排出不同的流年表,
672
673
  // 「同参数同盘」的承诺就只在当年成立。
673
674
  const asOfForLiunian = asOfParts(input.asOf);
674
- const thisYear = (asOfForLiunian && asOfForLiunian.year) || new Date().getFullYear();
675
+ const thisYear = (asOfForLiunian && asOfForLiunian.year) || new Date().getFullYear(); // allow-clock: 未传 asOf 时按本机年份起流年;工具层 asOf 可钉死
675
676
  const liunian = [];
676
677
  for (let y = thisYear; y < thisYear + 12; y++) {
677
678
  const gz = ganzhiName((((y - 4) % 60) + 60) % 60);
@@ -1323,7 +1324,7 @@ export function asOfParts(v) {
1323
1324
  }
1324
1325
 
1325
1326
  export function flowOf(chart, asOf) {
1326
- const now = new Date();
1327
+ const now = new Date(); // allow-clock: 流日/流时按调用时刻;显式 asOf 会覆盖
1327
1328
  const a = asOfParts(asOf) || {};
1328
1329
  const Y = a.year || now.getFullYear();
1329
1330
  const M = a.month || now.getMonth() + 1;