dsh-metaphysics 0.59.1 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -196,13 +196,19 @@ export function termInstantJd(year, index) {
196
196
  export function jdToParts(jdLocal) {
197
197
  const jdn = Math.floor(jdLocal + 0.5);
198
198
  const frac = jdLocal + 0.5 - jdn;
199
- const totalMinutes = Math.round(frac * 1440);
199
+ // 按「整分钟向下取」而不是四舍五入:23:59:45 一进位就成了 1440 分,
200
+ // 旧写法把小时取模、日序不动,于是这一瞬时塌回「当日 00:00」——
201
+ // 日柱退一天、partsToJd 往返回来差将近一天(晚子时换日与真太阳时跨午夜正好踩在刀口上)。
202
+ // 向下取后 hour/minute 与 jdn 永远自洽:拆出来的那一天,就是这一刻所在的那一天。
203
+ // 但向下取会被浮点噪声咬住:JD 里的 13:00 常算成 779.99999 分,直接 floor 就是 12:59,
204
+ // 未时变成午时。先归到最近的整秒(输入精度到分,误差远小于一秒),再向下取整分。
205
+ const totalMinutes = Math.min(1439, Math.floor(Math.round(frac * 86400) / 60));
200
206
  const { year, month, day } = fromJDN(jdn);
201
207
  return {
202
208
  year,
203
209
  month,
204
210
  day,
205
- hour: Math.floor(totalMinutes / 60) % 24,
211
+ hour: Math.floor(totalMinutes / 60),
206
212
  minute: totalMinutes % 60,
207
213
  };
208
214
  }
package/engine/dst.js CHANGED
@@ -34,6 +34,26 @@ export function inDstSpan(t) {
34
34
  return true;
35
35
  }
36
36
 
37
+ /**
38
+ * 换表当天绕不开的两个钟面区间:
39
+ * - 起算日 02:00–02:59:钟表从 02:00 直接拨到 03:00,这个读数在当年根本不存在;
40
+ * - 终止日 01:00–01:59:钟表往回拨一小时,同一读数会走两遍,对应两个真实时刻。
41
+ * 两者都不是「算错了」,而是输入本身有歧义,需要告诉调用方我们按哪种读法折的。
42
+ * @param {{year:number,month:number,day:number,hour:number,minute?:number}} t
43
+ * @returns {'gap'|'twice'|null}
44
+ */
45
+ export function dstAmbiguity(t) {
46
+ const span = DST_SPANS[t.year];
47
+ if (!span) return null;
48
+ const [sy, sm, sd] = span[0].split('-').map(Number);
49
+ const [ey, em, ed] = span[1].split('-').map(Number);
50
+ const n = ORD(t.year, t.month, t.day);
51
+ const minutes = t.hour * 60 + (t.minute || 0);
52
+ if (n === ORD(sy, sm, sd) && minutes >= 120 && minutes < 180) return 'gap';
53
+ if (n === ORD(ey, em, ed) && minutes >= 60 && minutes < 120) return 'twice';
54
+ return null;
55
+ }
56
+
37
57
  /**
38
58
  * 夏令时校正:钟面时间减 1 小时(跨日正确回退)。
39
59
  * @param {{year:number,month:number,day:number,hour:number,minute?:number}} t
@@ -53,10 +73,17 @@ export function applyDst(t) {
53
73
  day = daysInMonth(year, month);
54
74
  }
55
75
  }
76
+ const amb = dstAmbiguity(t);
56
77
  return {
57
78
  year, month, day, hour, minute,
58
79
  applied: true,
59
- note: `检出自 ${DST_SPANS[t.year][0]} 至 ${DST_SPANS[t.year][1]} 的中国夏令时:出生钟面时间已减 1 小时校正`,
80
+ note: `检出自 ${DST_SPANS[t.year][0]} 至 ${DST_SPANS[t.year][1]} 的中国夏令时:出生钟面时间已减 1 小时校正`
81
+ + (amb === 'gap'
82
+ ? ';这一天 02:00–03:00 钟表直接跳过,所给钟面时间在当年并不存在——这里按「拨快后的读数」回退一小时,'
83
+ + '若登记的本是标准时间,请传 dst:false 重排。'
84
+ : amb === 'twice'
85
+ ? ';这一天 01:00–02:00 因回拨走了两遍,同一钟面时间对应两个真实时刻——这里按夏令时的那一遍(回退一小时)取。'
86
+ : ''),
60
87
  };
61
88
  }
62
89
 
package/engine/handle.js CHANGED
@@ -23,7 +23,7 @@ const PREFIX = 'mc1.';
23
23
  * 反过来,seed / asOf / direction / 各流派开关都必须留下——少了它们就复现不出同一张盘。
24
24
  */
25
25
  const SKIP = new Set([
26
- 'handle', 'trace', 'shadow', 'shadowVersion', 'diff', 'withInterpret', 'cross',
26
+ 'handle', 'trace', 'shadow', 'shadowVersion', 'diff', 'withInterpret', 'detail', 'cross',
27
27
  'verifyPast', 'rectify', 'events', 'ask', 'followup', 'question', 'geocode', 'caliber',
28
28
  // 一句话改参是把原话翻成补丁:补丁已经落在其余参数里,原话本身不进句柄,
29
29
  // 否则同一张盘换个说法就换一个句柄,回放时还会再套一遍补丁。
@@ -171,6 +171,9 @@ export function decodeHandle(handle) {
171
171
  return payload;
172
172
  }
173
173
 
174
+ /** 不能作为增量键的名字:`out['__proto__'] = v` 走的是 setter,会把 out 的原型换掉 */
175
+ const KEY_BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
176
+
174
177
  /**
175
178
  * 把 handle 与增量参数并起来。没有 handle 时原样返回(深一层的浅拷贝,避免改调用方对象)。
176
179
  * 增量优先:{ handle, asOf: 2030 } 只覆盖 asOf,其余沿用原参数。
@@ -183,7 +186,8 @@ export function resolveHandle(args = {}) {
183
186
  // 显式的 undefined 当作「没传」:否则 { handle, datetime: undefined } 会把句柄里的日期抹掉
184
187
  const out = { ...base };
185
188
  for (const [k, v] of Object.entries(rest)) {
186
- if (v !== undefined) out[k] = v;
189
+ if (v === undefined || KEY_BLOCKED.has(k)) continue;
190
+ out[k] = v;
187
191
  }
188
192
  return out;
189
193
  }
package/engine/index.js CHANGED
@@ -76,13 +76,17 @@ function ziweiSchoolArgs(args) {
76
76
  return out;
77
77
  }
78
78
 
79
- /** 解析 'YYYY-MM-DDTHH:mm' / 'YYYY-MM-DD HH:mm' / 'YYYY-MM-DD' */
79
+ /** 解析 'YYYY-MM-DDTHH:mm' / 'YYYY-MM-DD HH:mm' / 'YYYY-MM-DD'
80
+ * 可以带秒(用户常直接粘 ISO 串),但引擎的民用时间口径到分钟:秒只校验、不参与换算。 */
80
81
  export function parseDateTime(text) {
81
82
  if (typeof text !== 'string' || !text.trim()) {
82
83
  throw fail('E_INPUT', '缺少 datetime(公历日期时间),例如 1990-05-12T14:30');
83
84
  }
84
85
  const m = text.trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T\s](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/);
85
86
  if (!m) throw fail('E_INPUT', `无法解析 datetime:「${text}」,请用 1990-05-12T14:30 这种格式`);
87
+ // 秒位以前只捕获不校验:\d{1,2} 放过 0–99,于是「T03:04:99」被静默折成 03:04。
88
+ // 与 1990-02-30 同一类问题——非法输入悄悄变成一个合法时刻,盘面照样排出,没人知道。
89
+ if (m[6] !== undefined && Number(m[6]) > 59) throw fail('E_INPUT', `秒超出范围:${m[6]}`);
86
90
  const out = {
87
91
  year: Number(m[1]), month: Number(m[2]), day: Number(m[3]),
88
92
  hour: Number(m[4] ?? 0), minute: Number(m[5] ?? 0),
@@ -517,7 +521,7 @@ function jsonSafe(value) {
517
521
  // ── 影子盘:同一组归一化参数 + 同一版本 → 同一个短 ID ──────────────
518
522
  // 只收影响盘面的参数:地址换成有效经度后不再把原文算进去,问事/方向按体系保留。
519
523
  const SHADOW_IGNORE = new Set([
520
- 'shadow', 'shadowVersion', 'diff', 'trace', 'traceError', 'withInterpret', 'cross', 'verifyPast', 'rectify', 'events',
524
+ 'shadow', 'shadowVersion', 'diff', 'trace', 'traceError', 'withInterpret', 'detail', 'cross', 'verifyPast', 'rectify', 'events',
521
525
  'geocode', 'ask', 'followup', 'caliber', 'seed', 'asOf', 'handle', 'revise',
522
526
  ]);
523
527
 
@@ -756,6 +760,8 @@ export function compute(input = {}) {
756
760
  args = { ...args };
757
761
  delete args.partner2;
758
762
  }
763
+ // 经度整形 + 守卫(非有限 / 超范围 → E_RANGE;数字串 → 数字)
764
+ args = normalizeLongitudes(args);
759
765
  const r = jsonSafe(computeRaw(args));
760
766
  // 「所问之事」以前只有六爻和塔罗会落到结果里,梅花/六壬/奇门/小六壬都丢掉了:
761
767
  // 于是第一屏想说「你问的是……」却拿不到那句话。这里统一补齐,已有的值不动。
@@ -804,11 +810,15 @@ ${r.summary}`;
804
810
  ${r.summary}`;
805
811
  }
806
812
  // 显式要求时才附加解析模块,避免默认输出过长;「所问之事」与「询问方向」一并传进去
813
+ const tier = detailOf(args.detail);
807
814
  if (args.withInterpret) {
808
815
  r.interpret = interpret(r, {
809
816
  question: typeof args.question === 'string' ? args.question : '',
810
817
  direction: typeof args.direction === 'string' ? args.direction : 'general',
811
818
  });
819
+ // 档位只改「回给模型的文本」,不改解析对象:面板拿到的仍是完整的 interpret。
820
+ // 键名不用 detail / tier:合盘结果里 r.detail 是详批对象、流年行里 r.tier 是档位,都会撞。
821
+ if (tier === 'lean') r.interpretTier = 'lean';
812
822
  }
813
823
  // 八字 × 紫微交叉验证:同一份出生信息再排另一套盘,并列两边对同一件事的说法
814
824
  // 交叉验证要再排另一套盘:时辰未知时紫微排不出来,这条支路直接跳过,
@@ -904,9 +914,13 @@ ${r.summary}`;
904
914
  return r;
905
915
  }
906
916
 
907
- /** 解析与详解的紧凑文本(给模型看,不塞整块 JSON) */
908
- export function interpretText(i) {
917
+ /** 解析与详解的紧凑文本(给模型看,不塞整块 JSON)
918
+ * detail:'full'(默认,与历史输出逐字一致)或 'lean'(只给结论、你问的那条与三张表,
919
+ * 长论述与「规则·出处」折成一行指针)。省的是回给模型的文本,不改盘面、不改解析对象本身。 */
920
+ export function interpretText(i, detail) {
909
921
  if (!i) return '';
922
+ const lean = detail === 'lean';
923
+ const LONG_SECTION = 300;
910
924
  const out = [`【${i.title || '解析与详解'}】${i.ask && i.ask.echo ? i.ask.echo : ''}`];
911
925
  // 直答放在最前:先回答「你问的事」,再说依据,避免读者读到最后才知道结论;
912
926
  // 没填问题时就不该喊「答所问」——改为「先看结论」,否则是一句假话
@@ -919,16 +933,27 @@ export function interpretText(i) {
919
933
  }
920
934
  // 解析生成失败时不能只返回一个空壳:盘面数据仍然可用,但要让调用方知道"这段没生成出来"
921
935
  if (i.error) out.push(`⚠ 解析生成失败:${i.error}(上方盘面数据本身可用)`);
922
- for (const d of i.dimensions || []) {
936
+ const allDims = i.dimensions || [];
937
+ // 问了方向就只给那一条:其余四条在 full 档里,模型要展开随时可以再排一次
938
+ const dims = lean && allDims.some((d) => d && d.focused) ? allDims.filter((d) => d && d.focused) : allDims;
939
+ for (const d of dims) {
923
940
  const evidence = Array.isArray(d.evidence) && d.evidence.length ? `\n 依据:${d.evidence.join(';')}${d.confidence ? ` 置信度:${d.confidence}` : ''}` : '';
924
941
  const plain = d.plain ? `(${d.plain})` : '';
925
942
  // 知识条目:「为什么这样断」+出处+可信度标注,是结论与依据之外的第三层
926
- const know = (d.knowledge || [])
927
- .map((k) => `\n 规则·${k.gist}〔${k.statusLabel}〕${k.detail} 出处:${k.source}${k.note ? ` 注:${k.note}` : ''}`)
928
- .join('');
943
+ const know = lean
944
+ ? ((d.knowledge || []).length ? `\n 规则 ${d.knowledge.length} 条未展开(detail:"full" 给出原文与出处)` : '')
945
+ : (d.knowledge || [])
946
+ .map((k) => `\n 规则·${k.gist}〔${k.statusLabel}〕${k.detail} 出处:${k.source}${k.note ? ` 注:${k.note}` : ''}`)
947
+ .join('');
929
948
  out.push(`${d.label}:${plain}${d.text}${d.advice ? ` 建议:${d.advice}` : ''}${evidence}${know}`);
930
949
  }
931
- for (const s of i.sections || []) out.push(`${s.label}:${s.text}`);
950
+ const secs = i.sections || [];
951
+ const longSecs = lean ? secs.filter((s) => String(s.text || '').length > LONG_SECTION) : [];
952
+ for (const s of secs) {
953
+ if (lean && String(s.text || '').length > LONG_SECTION) continue;
954
+ out.push(`${s.label}:${s.text}`);
955
+ }
956
+ if (longSecs.length) out.push(`(另有 ${longSecs.length} 节长论述未展开:${longSecs.map((s) => s.label).join('、')}。需要原文与出处时传 detail:"full")`);
932
957
  // 三张表:大运 / 流年 / 触发事件(纯文本对齐,工具输出也能直接读)
933
958
  for (const t of i.tables || []) {
934
959
  if (!Array.isArray(t.rows) || !t.rows.length) continue;
@@ -952,7 +977,7 @@ export function interpretText(i) {
952
977
 
953
978
  /** 对照时不参与计算的参数:它们只影响解析/附加模块,不影响盘面 */
954
979
  // shadow / trace 是结果的派生展示,不是盘面口径;带进来会让影子盘回显把每组开关都算成「有变化」
955
- const CALIBER_IGNORE_ARGS = ['cross', 'verifyPast', 'withInterpret', 'question', 'direction', 'ask', 'geocode', 'rectify', 'events', 'shadow', 'trace'];
980
+ const CALIBER_IGNORE_ARGS = ['cross', 'verifyPast', 'withInterpret', 'detail', 'question', 'direction', 'ask', 'geocode', 'rectify', 'events', 'shadow', 'trace'];
956
981
 
957
982
  /**
958
983
  * 口径对照:同一组输入,只把某一个口径开关换成它的另一档,逐字段列出变化。
@@ -1020,15 +1045,62 @@ const JU_METHODS = ['chaibu', 'chaoshen', 'zhirun'];
1020
1045
  const TAROT_COUNTS = [1, 3, 5];
1021
1046
  const LATE_ZI_MODES = ['same-day', 'next-day'];
1022
1047
 
1048
+ /**
1049
+ * 经度守卫:入口只判 `typeof === 'number'`,于是 JSON 里的 1e999(= Infinity)
1050
+ * 会一路走到真太阳时里炸成裸 TypeError,181 这类超范围值则静默排出另一张盘。
1051
+ * 两者都比报错坏:一个把堆栈式文案交给模型,一个把错结果当成对结果说出去。
1052
+ */
1053
+ function checkLongitude(value, who = '') {
1054
+ if (value === undefined || value === null || value === '') return undefined;
1055
+ const n = Number(value);
1056
+ const tag = who ? `${who}的` : '';
1057
+ if (!Number.isFinite(n)) throw fail('E_RANGE', `${tag}经度不是有限数值:收到「${value}」。经度写十进制数字即可,例如北京 116.4`);
1058
+ if (n < -180 || n > 180) throw fail('E_RANGE', `${tag}经度要在 −180 到 180 之间(东经为正),收到 ${n}`);
1059
+ return n;
1060
+ }
1061
+
1062
+ /**
1063
+ * 把主方 / 对方 / 第三方的经度统一成数字。
1064
+ * 下游按 `typeof === 'number'` 取值,所以 "86.0"(模型常给带引号的数)会被静默丢掉,
1065
+ * 少一次真太阳时修正而结果里毫无痕迹——这里先整形,再交给校验过的值。
1066
+ */
1067
+ function normalizeLongitudes(args) {
1068
+ const self = checkLongitude(args.longitude, '');
1069
+ const p = args.partner && typeof args.partner === 'object' ? checkLongitude(args.partner.longitude, '对方') : undefined;
1070
+ const p2 = args.partner2 && typeof args.partner2 === 'object' ? checkLongitude(args.partner2.longitude, '第三方') : undefined;
1071
+ const dirty = (self !== undefined && args.longitude !== self)
1072
+ || (p !== undefined && args.partner.longitude !== p)
1073
+ || (p2 !== undefined && args.partner2.longitude !== p2);
1074
+ if (!dirty) return args;
1075
+ const out = { ...args };
1076
+ if (self !== undefined) out.longitude = self;
1077
+ if (p !== undefined) out.partner = { ...out.partner, longitude: p };
1078
+ if (p2 !== undefined) out.partner2 = { ...out.partner2, longitude: p2 };
1079
+ return out;
1080
+ }
1081
+
1082
+ /** 输出档位:缺省与 'full' 都是历史全文;'lean' 只回结论与你问的那一条,省模型上下文。
1083
+ * 其它值以前会被当成「缺省」静默放过,写了 detail:'brief' 的人以为自己省了钱,其实没有。 */
1084
+ function detailOf(value) {
1085
+ if (value === undefined || value === null || value === '' || value === 'full') return 'full';
1086
+ if (value === 'lean') return 'lean';
1087
+ throw fail('E_INPUT', `detail 只能是 'full' 或 'lean':收到「${String(value).slice(0, 24)}」`);
1088
+ }
1089
+
1023
1090
  /** asOf 只接受能解析成真实日期的字符串;非法值以前会静默回退到「此刻」 */
1024
1091
  function checkAsOf(asOf) {
1025
1092
  if (asOf === undefined || asOf === null || asOf === '') return undefined;
1026
1093
  const parts = asOfParts(asOf);
1027
1094
  if (!parts) throw fail('E_ASOF', `无法解析 asOf:「${asOf}」,请用 2026-10-22T16:00 这种格式`);
1028
- const { year, month, day } = parts;
1095
+ const { year, month, day, hour, minute } = parts;
1029
1096
  if (!(month >= 1 && month <= 12) || !(day >= 1 && day <= daysInMonth(year, month))) {
1030
1097
  throw fail('E_ASOF', `asOf 指向的日期不存在:${asOf}`);
1031
1098
  }
1099
+ // 时分以前不校验:asOf: '2026-06-01T25:99' 会被接受并原样回显,
1100
+ // 而小时又按 mod 24 折回当日子时——流日/流时静默错位,比报错更坏。
1101
+ // 与 parseDateTime 同一把尺:给了就必须在范围内,缺省才由下游补默认值。
1102
+ if (hour !== undefined && !(hour >= 0 && hour <= 23)) throw fail('E_ASOF', `asOf 的小时要在 0–23 之间:${asOf}`);
1103
+ if (minute !== undefined && !(minute >= 0 && minute <= 59)) throw fail('E_ASOF', `asOf 的分钟要在 0–59 之间:${asOf}`);
1032
1104
  return parts;
1033
1105
  }
1034
1106
 
@@ -1074,7 +1146,7 @@ function computeRaw(args = {}) {
1074
1146
  // 流月/流日/流时的基准时刻:默认本机此刻,可用 asOf 指定以便复现
1075
1147
  asOf,
1076
1148
  });
1077
- return { ...result, precision: dt.precision, summary: ziweiText(result) };
1149
+ return { ...result, ...(asOf ? { asOf } : {}), precision: dt.precision, summary: ziweiText(result) };
1078
1150
  }
1079
1151
  if (system === 'tarot') {
1080
1152
  const count = args.count === undefined || args.count === null || args.count === '' ? 3 : Number(args.count);
@@ -1733,7 +1805,7 @@ export function renderText(result) {
1733
1805
  + result.trace.map((t, i) => `${i + 1}. ${t.field}|${t.result}\n 规则:${t.rule} 出处:${t.source}`).join('\n'));
1734
1806
  }
1735
1807
  if (result.interpret) {
1736
- const extra = interpretText(result.interpret);
1808
+ const extra = interpretText(result.interpret, result.interpretTier);
1737
1809
  if (extra) parts.push(extra);
1738
1810
  }
1739
1811
  if (result.cross) parts.push(`【八字 × 紫微 交叉验证】\n${crossText(result.cross)}`);
package/engine/lunar.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // 农历(定朔):朔望月计算 + 冬至所在月为十一月 + 无中气置闰
2
2
  import {
3
- toJDN, jdToParts, termInstantJd, deltaTSeconds, localOffsetFor, OFFSET_MODERN,
3
+ toJDN, jdToParts, termInstantJd, deltaTSeconds, localOffsetFor,
4
4
  } from './calendar.js';
5
5
 
6
6
  const SYNODIC = 29.530588861;
@@ -63,12 +63,11 @@ export function dayNumberOf(jd) {
63
63
  return Math.floor(jd + 0.5);
64
64
  }
65
65
 
66
- // 1929 年前后的时区切换常量与换算统一放在 calendar.js(节气与农历共用)
67
- /** 把 calendar 给出的「东八区轴」时刻换成当时的地方轴 */
68
- function toHistoricalLocal(jdBeijing) {
69
- const jdUt = jdBeijing - OFFSET_MODERN;
70
- return jdUt + localOffsetFor(jdUt);
71
- }
66
+ // 时区切换常量与换算统一在 calendar.js:termInstantJd 与 newMoonJd 都已经落在
67
+ // 「当时的本地时间轴」上(1929 前北京地方平时,之后东八区),两边直接比日序就行。
68
+ // 以前这里再套一次 localOffset 把节气拉回本地轴,等于对同一个量移了两次时:
69
+ // 净偏移约 −12 分,节气时刻一旦落在午夜前后十几分钟内就少算一天,
70
+ // 中气归属错一个月,置闰跟着整月错位(1917 报闰三、真历表闰二;1922 报闰六、真历表闰五)。
72
71
 
73
72
  function kNear(jdDay) {
74
73
  return Math.round((jdDay + 0.5 - NEW_MOON_EPOCH) / SYNODIC);
@@ -78,7 +77,7 @@ function kNear(jdDay) {
78
77
  function hasZhongQi(year, startDay, endDay) {
79
78
  for (let y = year - 1; y <= year + 1; y++) {
80
79
  for (let i = 1; i < 24; i += 2) {
81
- const jdLocal = toHistoricalLocal(termInstantJd(y, i));
80
+ const jdLocal = termInstantJd(y, i);
82
81
  const d = dayNumberOf(jdLocal);
83
82
  if (d >= startDay && d < endDay) return { index: i, jd: jdLocal };
84
83
  }
@@ -93,9 +92,9 @@ function hasZhongQi(year, startDay, endDay) {
93
92
  export function solarToLunar(year, month, day) {
94
93
  const targetDay = toJDN(year, month, day);
95
94
 
96
- const wsDayThisYear = dayNumberOf(toHistoricalLocal(termInstantJd(year, 23)));
95
+ const wsDayThisYear = dayNumberOf(termInstantJd(year, 23));
97
96
  const wsYear = targetDay < wsDayThisYear ? year - 1 : year;
98
- const wsDay = dayNumberOf(toHistoricalLocal(termInstantJd(wsYear, 23)));
97
+ const wsDay = dayNumberOf(termInstantJd(wsYear, 23));
99
98
 
100
99
  // 十一月朔:日序不晚于冬至的最后一个朔日
101
100
  let k = kNear(wsDay);
@@ -105,7 +104,7 @@ export function solarToLunar(year, month, day) {
105
104
  // ── 置闰前提(国标口径)──
106
105
  // 只有「两个冬至月之间有 13 个朔望月」才需要置闰,且取其中第一个无中气的月。
107
106
  // 若只有 12 个月,即使某月无中气也不闰(1985 正月、2053 正月就是这种反例)。
108
- const nextWsDay = dayNumberOf(toHistoricalLocal(termInstantJd(wsYear + 1, 23)));
107
+ const nextWsDay = dayNumberOf(termInstantJd(wsYear + 1, 23));
109
108
  let kNext = k;
110
109
  while (dayNumberOf(newMoonJd(kNext + 1)) <= nextWsDay) kNext++;
111
110
  const cycleMonths = kNext - k;
package/engine/random.js CHANGED
@@ -3,7 +3,15 @@
3
3
 
4
4
  /** mulberry32:32 位状态,分布够用,实现只有几行 */
5
5
  export function mulberry32(seed) {
6
- let a = Number(seed) >>> 0;
6
+ const n = Number(seed);
7
+ // 状态只有 32 位,但不能只截低 32 位(`>>> 0`):相差 2^32 的种子会跑出同一串数。
8
+ // 拿毫秒时间戳当种子时,隔 49.7 天的两次摇卦会得到完全一样的卦——用户换了种子却看不出区别,
9
+ // 「可复现」就退成了「可猜测」。所以把高位与小数位各折一份进状态;
10
+ // 整数种子(0…2^31)的低 32 位仍原样参与,小种子不会莫名其妙换一卦。
11
+ const lo = n >>> 0;
12
+ const hi = Math.floor(Math.abs(n) / 4294967296) >>> 0;
13
+ const frac = Math.round((n - Math.trunc(n)) * 4294967296) >>> 0;
14
+ let a = (lo ^ Math.imul(hi + 1, 0x85ebca6b) ^ frac) >>> 0;
7
15
  if (!a) a = 0x9e3779b9;
8
16
  return function next() {
9
17
  a = (a + 0x6d2b79f5) >>> 0;
package/engine/rectify.js CHANGED
@@ -9,6 +9,8 @@ import { buildBazi } from './bazi.js';
9
9
 
10
10
  const KINDS = ['career', 'wealth', 'love', 'move', 'health', 'other'];
11
11
  const HOURS = [0, 1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 23];
12
+ /** 事件条数上限:对账用不到几百条,超限是输入异常而不是需求 */
13
+ const MAX_EVENTS = 200;
12
14
 
13
15
  const TEN_GOD = (l) => l.stemTenGod || '';
14
16
  const has = (l, re) => re.test((l.relations || []).join(''));
@@ -67,10 +69,29 @@ export function rectifyBirth(opts) {
67
69
  e.code = 'E_INPUT';
68
70
  throw e;
69
71
  }
70
- const nowYear = Math.min(...clean.map((x) => x.year));
71
- const hours = Array.isArray(opts.hours) && opts.hours.length
72
- ? opts.hours.filter((h) => Number.isInteger(h) && h >= 0 && h <= 23)
72
+ // 事件只用来对账,几十条已足够。不设上限的后果很具体:20 万条会原样回显成 2MB 结果,
73
+ // 并且下面的取最小值(Math.min(...arr) 逐元素入参)直接把栈打爆。
74
+ if (clean.length > MAX_EVENTS) {
75
+ const e = new Error(`生时校正的事件最多 ${MAX_EVENTS} 条,收到 ${clean.length} 条——只报最近几十年里确实发生过的事即可`);
76
+ e.code = 'E_INPUT';
77
+ throw e;
78
+ }
79
+ if (clean.some((x) => x.year < 1600 || x.year > 2400)) {
80
+ const e = new Error('事件年份要落在 1600–2400 之间(与排盘支持的年份一致)');
81
+ e.code = 'E_INPUT';
82
+ throw e;
83
+ }
84
+ const nowYear = clean.reduce((m, x) => (x.year < m ? x.year : m), clean[0].year);
85
+ // 候选时辰同样要限:一天只有 12 个时辰,给了 1000 个就是白排 1000 张盘
86
+ const wanted = Array.isArray(opts.hours) && opts.hours.length
87
+ ? [...new Set(opts.hours.filter((h) => Number.isInteger(h) && h >= 0 && h <= 23))]
73
88
  : HOURS;
89
+ if (!wanted.length) {
90
+ const e = new Error('候选时辰里没有有效的小时(0–23 的整数)');
91
+ e.code = 'E_INPUT';
92
+ throw e;
93
+ }
94
+ const hours = wanted.slice(0, 24);
74
95
 
75
96
  const candidates = hours.map((hour) => {
76
97
  const chart = buildBazi({
package/engine/trend.js CHANGED
@@ -44,21 +44,37 @@ function tenGodOfZhi(dayStem, branch) {
44
44
 
45
45
  const yearGanzhi = (year) => ganzhiName(((year - 4) % 60 + 60) % 60);
46
46
 
47
- /** 把大运表补到覆盖 toAge(引擎默认只排 8 步 / 80 年)。 */
48
- function extendDayun(list, direction, toAge) {
49
- const out = list.slice();
47
+ /**
48
+ * 大运分段,按「交运那一年」切,年龄落在与 points[].age 同一条虚岁轴上。
49
+ *
50
+ * 不能直接用 dayun.startAge:那是周岁(三日用折一年从出生日算起),
51
+ * 而 K 线一年一格、`age` 就是虚岁(= 年份 − 出生年 + 1,与八字流年表同一口径)。
52
+ * 两套轴混在一起会让整条线提前一年:1990-05-12 男命 1998-08 交壬午,
53
+ * 旧写法把 1997(还没起运)划进壬午,2007(仍在壬午)却标成癸未。
54
+ * 交运日期本来就是同一份数据的精确表达,所以按它的年份起算;
55
+ * 引擎只排八步,八步之外按十年一格推年份,干支仍按六十甲子顺/逆推。
56
+ */
57
+ function buildSegments(list, direction, birthYear, toAge) {
58
+ const toYear = birthYear + toAge - 1;
50
59
  const step = direction === '逆行' ? -1 : 1;
60
+ const years = [];
61
+ for (const d of list) years.push({ ganZhi: d.ganzhi, startYear: Number(String(d.fromDate).slice(0, 4)) });
51
62
  let guard = 0;
52
- while (out.length && out[out.length - 1].startAge <= toAge && guard < 24) {
53
- const last = out[out.length - 1];
54
- // last.ganzhi 是「甲辰」这样的名字,不是六十甲子序号:先换回序号再顺/逆推一步
55
- const lastIdx = sexagenaryIndex(STEMS.indexOf(last.ganzhi[0]), BRANCHES.indexOf(last.ganzhi[1]));
56
- const nextIdx = (lastIdx + step + 60) % 60;
57
- const g = ganzhiName(nextIdx);
58
- out.push({ step: last.step + 1, startAge: last.startAge + 10, ganzhi: g.name, stem: g.stem, branch: g.branch, stemTenGod: last.stemTenGod });
63
+ while (years.length && years[years.length - 1].startYear <= toYear && guard < 24) {
64
+ const last = years[years.length - 1];
65
+ // ganZhi 是「甲辰」这样的名字,不是六十甲子序号:先换回序号再顺/逆推一步
66
+ const lastIdx = sexagenaryIndex(STEMS.indexOf(last.ganZhi[0]), BRANCHES.indexOf(last.ganZhi[1]));
67
+ const g = ganzhiName((lastIdx + step + 60) % 60);
68
+ years.push({ ganZhi: g.name, startYear: last.startYear + 10 });
59
69
  guard++;
60
70
  }
61
- return out;
71
+ return years
72
+ .map((s, i) => ({
73
+ ganZhi: s.ganZhi,
74
+ startAge: s.startYear - birthYear + 1,
75
+ endAge: (years[i + 1] ? years[i + 1].startYear : s.startYear + 10) - birthYear,
76
+ }))
77
+ .filter((s) => s.startAge <= toAge);
62
78
  }
63
79
 
64
80
  /**
@@ -94,16 +110,9 @@ export function buildTrend(input) {
94
110
  const scoreOf = (god) => table[god] ?? 0;
95
111
  const boundary = LEVEL_CUTS.some((c) => Math.abs(r.strength.score - c) <= BOUNDARY_EPS);
96
112
 
97
- const dayun = extendDayun(r.dayun.list, r.dayun.direction, toAge);
98
- const segments = dayun
99
- .map((d, i) => ({
100
- ganZhi: d.ganzhi,
101
- startAge: d.startAge,
102
- endAge: (dayun[i + 1] ? dayun[i + 1].startAge : d.startAge + 10) - 1,
103
- }))
104
- .filter((s) => s.startAge <= toAge);
105
-
106
113
  const birthYear = Number(String(r.solarTime.civilText).slice(0, 4));
114
+ const segments = buildSegments(r.dayun.list, r.dayun.direction, birthYear, toAge);
115
+
107
116
  const points = [];
108
117
  let prev = 50;
109
118
  for (let age = 1; age <= toAge; age++) {
package/engine/zeri.js CHANGED
@@ -2,6 +2,11 @@
2
2
  // 口径透明:这是排序引导,不是吉凶断语;未收录逐日神煞,冲煞按日支对生肖。
3
3
  import { fromJDN, toJDN } from './calendar.js';
4
4
  import { buildAlmanac, SHENGXIAO } from './almanac.js';
5
+ import { fail } from './fail.js';
6
+
7
+ // 择日的入参错误一律带 E_INPUT:裸 new Error 到工具层只剩 [E_INTERNAL],
8
+ // 模型拿到的是一句没有指向性的文案,改不了参数。
9
+ const bad = (message) => fail('E_INPUT', message);
5
10
 
6
11
  /** 支持的事项:直接宜词 + 关联宜词(黄历措辞)。 */
7
12
  export const TASKS = {
@@ -104,39 +109,50 @@ const ymd = (p) => `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day
104
109
  */
105
110
  export function buildZeri(input) {
106
111
  const task = input.task;
107
- if (!TASKS[task]) throw new Error(`不支持的事项「${task}」。支持:${Object.keys(TASKS).join('、')}`);
112
+ if (!TASKS[task]) throw bad(`不支持的事项「${task}」。支持:${Object.keys(TASKS).join('、')}`);
108
113
  const shengXiaoRaw = typeof input.shengXiao === 'string' ? input.shengXiao.trim() : '';
109
114
  const shengXiao = shengXiaoRaw || null;
110
- if (shengXiao && !SHENGXIAO.includes(shengXiao)) throw new Error(`shengXiao 须为生肖名:${SHENGXIAO.join('、')}`);
115
+ if (shengXiao && !SHENGXIAO.includes(shengXiao)) throw bad(`shengXiao 须为生肖名:${SHENGXIAO.join('、')}`);
111
116
 
112
117
  // 候选日模式:只比用户给的这几天,不再按区间扫
113
118
  let candidates = null;
114
119
  if (input.candidates !== undefined && input.candidates !== null) {
115
120
  if (!Array.isArray(input.candidates)) {
116
- throw new Error('candidates 须为日期数组,例如 ["2026-12-01","2026-12-08","2026-12-15"]');
121
+ throw bad('candidates 须为日期数组,例如 ["2026-12-01","2026-12-08","2026-12-15"]');
117
122
  }
118
- if (input.candidates.length < 2) throw new Error('candidates 至少给 2 个候选日;只想看一天请用 startDate + days: 1');
119
- if (input.candidates.length > 12) throw new Error('candidates 最多 12 个候选日;更多请用 startDate + days 扫区间');
123
+ if (input.candidates.length < 2) throw bad('candidates 至少给 2 个候选日;只想看一天请用 startDate + days: 1');
124
+ if (input.candidates.length > 12) throw bad('candidates 最多 12 个候选日;更多请用 startDate + days 扫区间');
120
125
  const seen = new Set();
121
126
  candidates = [];
122
127
  for (const raw of input.candidates) {
123
128
  const p = parseYmd(raw);
124
- if (!p) throw new Error(`候选日不是真实存在的公历日期:「${raw}」,请用 2026-12-01 这种格式`);
129
+ if (!p) throw bad(`候选日不是真实存在的公历日期:「${raw}」,请用 2026-12-01 这种格式`);
125
130
  const key = ymd(p);
126
131
  if (seen.has(key)) continue;
127
132
  seen.add(key);
128
133
  candidates.push(p);
129
134
  }
130
- if (candidates.length < 2) throw new Error('候选日去重后不足 2 天;至少要两个不同的日子才谈得上比较');
135
+ if (candidates.length < 2) throw bad('候选日去重后不足 2 天;至少要两个不同的日子才谈得上比较');
131
136
  }
132
137
 
133
138
  let start = null;
134
139
  let days = 0;
135
140
  if (!candidates) {
136
141
  const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(String(input.startDate || '').trim());
137
- if (!m) throw new Error('择日需要 startDate(公历 YYYY-MM-DD)——请向用户询问起算日,不要代填');
142
+ if (!m) throw bad('择日需要 startDate(公历 YYYY-MM-DD)——请向用户询问起算日,不要代填');
138
143
  start = { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) };
139
- days = Math.max(1, Math.min(60, Number.isInteger(input.days) ? input.days : 30));
144
+ // days 以前只认 Number.isInteger,于是 "45"(模型很爱给字符串)与 45.5 都静默变成 30 天:
145
+ // 用户要的区间被悄悄改短,而结果里只看得到实际扫了几天,看不出被改过。
146
+ // 现在:能规范成整数的(含数字串)就规范,范围外或非数字一律报错,不猜。
147
+ const rawDays = input.days;
148
+ if (rawDays === undefined || rawDays === null || rawDays === '') {
149
+ days = 30;
150
+ } else {
151
+ const n = Number(rawDays);
152
+ if (!Number.isInteger(n)) throw bad(`择日的 days 要是 1–60 的整数,收到「${rawDays}」`);
153
+ if (n < 1 || n > 60) throw bad(`择日的 days 要在 1–60 之间(${n} 超出范围);更长请用分段多次查询`);
154
+ days = n;
155
+ }
140
156
  }
141
157
 
142
158
  const spec = TASKS[task];
package/engine/ziwei.js CHANGED
@@ -647,8 +647,11 @@ export function buildZiwei(input) {
647
647
  };
648
648
  })();
649
649
 
650
- // 流年(当年起 12 年)
651
- const thisYear = new Date().getFullYear();
650
+ // 流年(基准年起 12 年):基准年优先用调用方传来的 asOf,缺省才落本机当年。
651
+ // 直接读挂钟的话,同一组参数(同一个句柄)跨年会排出不同的流年表,
652
+ // 「同参数同盘」的承诺就只在当年成立。
653
+ const asOfForLiunian = asOfParts(input.asOf);
654
+ const thisYear = (asOfForLiunian && asOfForLiunian.year) || new Date().getFullYear();
652
655
  const liunian = [];
653
656
  for (let y = thisYear; y < thisYear + 12; y++) {
654
657
  const gz = ganzhiName((((y - 4) % 60) + 60) % 60);