dsh-metaphysics 0.14.2 → 0.15.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.
package/engine/liuyao.js CHANGED
@@ -1,227 +1,237 @@
1
- // 六爻(纳甲)引擎:摇卦、装卦、六亲、六神、世应、变卦
2
- import { STEMS, BRANCHES, toJDN, ganzhiName, dayGanzhiIndex } from './calendar.js';
3
-
4
- // 八卦三位(自下而上,1 = 阳)
5
- const TRIGRAM_BITS = {
6
- 乾: [1, 1, 1], 兑: [1, 1, 0], 离: [1, 0, 1], 震: [1, 0, 0],
7
- 巽: [0, 1, 1], 坎: [0, 1, 0], 艮: [0, 0, 1], 坤: [0, 0, 0],
8
- };
9
- // 位值 = b0 + 2b1 + 4b2
10
- const TRIGRAM_BY_VALUE = ['坤', '震', '坎', '兑', '艮', '离', '巽', '乾'];
11
-
12
- const bitsValue = (bits) => bits[0] + 2 * bits[1] + 4 * bits[2];
13
-
14
- // 京房八宫:每宫八卦次序 = 本宫、一世…五世、游魂、归魂
15
- const PALACE_NAMES = {
16
- 乾: ['乾为天', '天风姤', '天山遁', '天地否', '风地观', '山地剥', '火地晋', '火天大有'],
17
- 兑: ['兑为泽', '泽水困', '泽地萃', '泽山咸', '水山蹇', '地山谦', '雷山小过', '雷泽归妹'],
18
- 离: ['离为火', '火山旅', '火风鼎', '火水未济', '山水蒙', '风水涣', '天水讼', '天火同人'],
19
- 震: ['震为雷', '雷地豫', '雷水解', '雷风恒', '地风升', '水风井', '泽风大过', '泽雷随'],
20
- 巽: ['巽为风', '风天小畜', '风火家人', '风雷益', '天雷无妄', '火雷噬嗑', '山雷颐', '山风蛊'],
21
- 坎: ['坎为水', '水泽节', '水雷屯', '水火既济', '泽火革', '雷火丰', '地火明夷', '地水师'],
22
- 艮: ['艮为山', '山火贲', '山天大畜', '山泽损', '火泽睽', '天泽履', '风泽中孚', '风山渐'],
23
- 坤: ['坤为地', '地雷复', '地泽临', '地天泰', '雷天大壮', '泽天夬', '水天需', '水地比'],
24
- };
25
-
26
- const PALACE_ELEMENT = {
27
- 乾: '金', 兑: '金', 离: '火', 震: '木', 巽: '木', 坎: '水', 艮: '土', 坤: '土',
28
- };
29
-
30
- // 八宫卦序:世爻位置(1 = 初爻)
31
- const SHI_POSITION = [6, 1, 2, 3, 4, 5, 4, 3];
32
-
33
- // 京房纳甲:内卦 / 外卦 的天干与三爻地支
34
- const NAJIA = {
35
- 乾: { gan: ['甲', '壬'], inner: ['子', '寅', '辰'], outer: ['午', '申', '戌'] },
36
- 坤: { gan: ['乙', '癸'], inner: ['未', '巳', '卯'], outer: ['丑', '亥', '酉'] },
37
- 震: { gan: ['庚', '庚'], inner: ['子', '寅', '辰'], outer: ['午', '申', '戌'] },
38
- 巽: { gan: ['辛', '辛'], inner: ['丑', '亥', '酉'], outer: ['未', '巳', '卯'] },
39
- 坎: { gan: ['戊', '戊'], inner: ['寅', '辰', '午'], outer: ['申', '戌', '子'] },
40
- 离: { gan: ['己', '己'], inner: ['卯', '丑', '亥'], outer: ['酉', '未', '巳'] },
41
- 艮: { gan: ['丙', '丙'], inner: ['辰', '午', '申'], outer: ['戌', '子', '寅'] },
42
- 兑: { gan: ['丁', '丁'], inner: ['巳', '卯', '丑'], outer: ['亥', '酉', '未'] },
43
- };
44
-
45
- const BRANCH_ELEMENT = {
46
- 子: '水', 丑: '土', 寅: '木', 卯: '木', 辰: '土', 巳: '火',
47
- 午: '火', 未: '土', 申: '金', 酉: '金', 戌: '土', 亥: '水',
48
- };
49
-
50
- const GENERATES = { 木: '火', 火: '土', 土: '金', 金: '水', 水: '木' };
51
- const OVERCOMES = { 木: '土', 土: '水', 水: '火', 火: '金', 金: '木' };
52
-
53
- const LIUSHEN = ['青龙', '朱雀', '勾陈', '螣蛇', '白虎', '玄武'];
54
- // 日干 -> 初爻所起六神
55
- const LIUSHEN_START = { 甲: 0, 乙: 0, 丙: 1, 丁: 1, 戊: 2, 己: 3, 庚: 4, 辛: 4, 壬: 5, 癸: 5 };
56
-
57
- function liuqin(palaceElement, branchElement) {
58
- if (palaceElement === branchElement) return '兄弟';
59
- if (GENERATES[branchElement] === palaceElement) return '父母';
60
- if (GENERATES[palaceElement] === branchElement) return '子孙';
61
- if (OVERCOMES[branchElement] === palaceElement) return '官鬼';
62
- if (OVERCOMES[palaceElement] === branchElement) return '妻财';
63
- return '?';
64
- }
65
-
66
- // 构建 (上卦, 下卦) -> 卦 的索引
67
- const HEXAGRAM_INDEX = new Map();
68
-
69
- function flip(bits, positions) {
70
- const out = bits.slice();
71
- for (const p of positions) out[p] = out[p] ? 0 : 1;
72
- return out;
73
- }
74
-
75
- for (const [palace, names] of Object.entries(PALACE_NAMES)) {
76
- const t = TRIGRAM_BITS[palace];
77
- const variants = [
78
- { lower: t, upper: t },
79
- { lower: flip(t, [0]), upper: t },
80
- { lower: flip(t, [0, 1]), upper: t },
81
- { lower: flip(t, [0, 1, 2]), upper: t },
82
- { lower: flip(t, [0, 1, 2]), upper: flip(t, [0]) },
83
- { lower: flip(t, [0, 1, 2]), upper: flip(t, [0, 1]) },
84
- { lower: flip(t, [0, 1, 2]), upper: flip(t, [1]) },
85
- { lower: t, upper: flip(t, [1]) },
86
- ];
87
- variants.forEach((v, order) => {
88
- const upper = TRIGRAM_BY_VALUE[bitsValue(v.upper)];
89
- const lower = TRIGRAM_BY_VALUE[bitsValue(v.lower)];
90
- HEXAGRAM_INDEX.set(`${upper}|${lower}`, {
91
- name: names[order],
92
- palace,
93
- palaceElement: PALACE_ELEMENT[palace],
94
- order,
95
- upper,
96
- lower,
97
- lines: [...v.lower, ...v.upper], // b0 初爻
98
- shi: SHI_POSITION[order],
99
- ying: SHI_POSITION[order] > 3 ? SHI_POSITION[order] - 3 : SHI_POSITION[order] + 3,
100
- });
101
- });
102
- }
103
-
104
- export const HEXAGRAM_COUNT = HEXAGRAM_INDEX.size;
105
-
106
- /** 由六爻(自下而上,1 = 阳)求卦 */
107
- export function hexagramFromBits(bits) {
108
- const upper = TRIGRAM_BY_VALUE[bitsValue(bits.slice(3))];
109
- const lower = TRIGRAM_BY_VALUE[bitsValue(bits.slice(0, 3))];
110
- return HEXAGRAM_INDEX.get(`${upper}|${lower}`);
111
- }
112
-
113
- /** 随机摇卦:三枚铜钱,背为阳(3),字为阴(2),合计 6/7/8/9 */
114
- export function tossLines(random = Math.random) {
115
- const lines = [];
116
- for (let i = 0; i < 6; i++) {
117
- let sum = 0;
118
- for (let c = 0; c < 3; c++) sum += random() < 0.5 ? 3 : 2;
119
- lines.push(sum); // 6 老阴 7 少阳 8 少阴 9 老阳
120
- }
121
- return lines;
122
- }
123
-
124
- function lineToBit(v) {
125
- return v === 7 || v === 9 ? 1 : 0;
126
- }
127
-
128
- function isMoving(v) {
129
- return v === 6 || v === 9;
130
- }
131
-
132
- /**
133
- * 装卦
134
- * @param {object} input { lines?: number[] (自下而上, 6/7/8/9), dayGanZhi?: {stem, branch}, question?: string }
135
- */
136
- export function buildLiuyao(input = {}) {
137
- const rawLines = Array.isArray(input.lines) && input.lines.length === 6
138
- ? input.lines.slice()
139
- : tossLines(input.random);
140
-
141
- const invalid = rawLines.filter((v) => ![6, 7, 8, 9].includes(v));
142
- if (invalid.length) throw new Error(`爻值只能是 6/7/8/9,收到 ${JSON.stringify(rawLines)}`);
143
-
144
- const bits = rawLines.map(lineToBit);
145
- const ben = hexagramFromBits(bits);
146
- const moving = rawLines.map((v, i) => (isMoving(v) ? i : -1)).filter((i) => i >= 0);
147
- const bianBits = moving.length ? flip(bits, moving) : null;
148
- const bian = bianBits ? hexagramFromBits(bianBits) : null;
149
-
150
- const dayGanZhi = input.dayGanZhi ?? (() => {
151
- const now = new Date();
152
- const jdn = toJDN(now.getFullYear(), now.getMonth() + 1, now.getDate());
153
- const g = ganzhiName(dayGanzhiIndex(jdn));
154
- return { stem: g.stem, branch: g.branch, name: g.name };
155
- })();
156
-
157
- const liushenStart = LIUSHEN_START[dayGanZhi.stem] ?? 0;
158
-
159
- function decorate(hex, movingSet) {
160
- const rows = [];
161
- for (let i = 0; i < 6; i++) {
162
- const isInner = i < 3;
163
- const trigram = isInner ? hex.lower : hex.upper;
164
- const nj = NAJIA[trigram];
165
- const branch = isInner ? nj.inner[i] : nj.outer[i - 3];
166
- const gan = isInner ? nj.gan[0] : nj.gan[1];
167
- const element = BRANCH_ELEMENT[branch];
168
- const value = rawLines[i];
169
- rows.push({
170
- position: i + 1,
171
- name: ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'][i],
172
- yinYang: bits[i] ? '阳' : '阴',
173
- moving: movingSet ? movingSet.has(i) : isMoving(value),
174
- symbol: bits[i] ? '━━━━━' : '━━ ━━',
175
- ganzhi: gan + branch,
176
- stem: gan,
177
- branch,
178
- element,
179
- liuqin: liuqin(hex.palaceElement, element),
180
- liushen: LIUSHEN[(liushenStart + i) % 6],
181
- });
182
- }
183
- return rows;
184
- }
185
-
186
- const benRows = decorate(ben, new Set(moving));
187
- const movingSet = new Set(moving);
188
- const bianRows = bian ? decorate(bian, new Set()) : null;
189
- if (bianRows) {
190
- bianRows.forEach((r, i) => {
191
- r.changedFrom = movingSet.has(i) ? benRows[i].symbol : null;
192
- });
193
- }
194
-
195
- return {
196
- system: 'liuyao',
197
- question: input.question ?? null,
198
- cast: {
199
- lines: rawLines,
200
- notation: rawLines.map((v) => ({ 6: '老阴 ✕', 7: '少阳', 8: '少阴', 9: '老阳 ○' }[v])),
201
- movingPositions: moving.map((i) => i + 1),
202
- },
203
- dayGanZhi: { name: dayGanZhi.name, stem: dayGanZhi.stem, branch: dayGanZhi.branch },
204
- ben: {
205
- name: ben.name,
206
- upper: ben.upper,
207
- lower: ben.lower,
208
- palace: `${ben.palace}宫`,
209
- palaceElement: ben.palaceElement,
210
- shi: ben.shi,
211
- ying: ben.ying,
212
- lines: benRows,
213
- },
214
- bian: bian
215
- ? {
216
- name: bian.name,
217
- upper: bian.upper,
218
- lower: bian.lower,
219
- palace: `${bian.palace}宫`,
220
- palaceElement: bian.palaceElement,
221
- shi: bian.shi,
222
- ying: bian.ying,
223
- lines: bianRows,
224
- }
225
- : null,
226
- };
227
- }
1
+ // 六爻(纳甲)引擎:摇卦、装卦、六亲、六神、世应、变卦
2
+ import { STEMS, BRANCHES, toJDN, ganzhiName, dayGanzhiIndex, sexagenaryIndex, xunKongOf } from './calendar.js';
3
+
4
+ // 八卦三位(自下而上,1 = 阳)
5
+ const TRIGRAM_BITS = {
6
+ 乾: [1, 1, 1], 兑: [1, 1, 0], 离: [1, 0, 1], 震: [1, 0, 0],
7
+ 巽: [0, 1, 1], 坎: [0, 1, 0], 艮: [0, 0, 1], 坤: [0, 0, 0],
8
+ };
9
+ // 位值 = b0 + 2b1 + 4b2
10
+ const TRIGRAM_BY_VALUE = ['坤', '震', '坎', '兑', '艮', '离', '巽', '乾'];
11
+
12
+ const bitsValue = (bits) => bits[0] + 2 * bits[1] + 4 * bits[2];
13
+
14
+ // 京房八宫:每宫八卦次序 = 本宫、一世…五世、游魂、归魂
15
+ const PALACE_NAMES = {
16
+ 乾: ['乾为天', '天风姤', '天山遁', '天地否', '风地观', '山地剥', '火地晋', '火天大有'],
17
+ 兑: ['兑为泽', '泽水困', '泽地萃', '泽山咸', '水山蹇', '地山谦', '雷山小过', '雷泽归妹'],
18
+ 离: ['离为火', '火山旅', '火风鼎', '火水未济', '山水蒙', '风水涣', '天水讼', '天火同人'],
19
+ 震: ['震为雷', '雷地豫', '雷水解', '雷风恒', '地风升', '水风井', '泽风大过', '泽雷随'],
20
+ 巽: ['巽为风', '风天小畜', '风火家人', '风雷益', '天雷无妄', '火雷噬嗑', '山雷颐', '山风蛊'],
21
+ 坎: ['坎为水', '水泽节', '水雷屯', '水火既济', '泽火革', '雷火丰', '地火明夷', '地水师'],
22
+ 艮: ['艮为山', '山火贲', '山天大畜', '山泽损', '火泽睽', '天泽履', '风泽中孚', '风山渐'],
23
+ 坤: ['坤为地', '地雷复', '地泽临', '地天泰', '雷天大壮', '泽天夬', '水天需', '水地比'],
24
+ };
25
+
26
+ const PALACE_ELEMENT = {
27
+ 乾: '金', 兑: '金', 离: '火', 震: '木', 巽: '木', 坎: '水', 艮: '土', 坤: '土',
28
+ };
29
+
30
+ // 八宫卦序:世爻位置(1 = 初爻)
31
+ const SHI_POSITION = [6, 1, 2, 3, 4, 5, 4, 3];
32
+
33
+ // 京房纳甲:内卦 / 外卦 的天干与三爻地支
34
+ const NAJIA = {
35
+ 乾: { gan: ['甲', '壬'], inner: ['子', '寅', '辰'], outer: ['午', '申', '戌'] },
36
+ 坤: { gan: ['乙', '癸'], inner: ['未', '巳', '卯'], outer: ['丑', '亥', '酉'] },
37
+ 震: { gan: ['庚', '庚'], inner: ['子', '寅', '辰'], outer: ['午', '申', '戌'] },
38
+ 巽: { gan: ['辛', '辛'], inner: ['丑', '亥', '酉'], outer: ['未', '巳', '卯'] },
39
+ 坎: { gan: ['戊', '戊'], inner: ['寅', '辰', '午'], outer: ['申', '戌', '子'] },
40
+ 离: { gan: ['己', '己'], inner: ['卯', '丑', '亥'], outer: ['酉', '未', '巳'] },
41
+ 艮: { gan: ['丙', '丙'], inner: ['辰', '午', '申'], outer: ['戌', '子', '寅'] },
42
+ 兑: { gan: ['丁', '丁'], inner: ['巳', '卯', '丑'], outer: ['亥', '酉', '未'] },
43
+ };
44
+
45
+ const BRANCH_ELEMENT = {
46
+ 子: '水', 丑: '土', 寅: '木', 卯: '木', 辰: '土', 巳: '火',
47
+ 午: '火', 未: '土', 申: '金', 酉: '金', 戌: '土', 亥: '水',
48
+ };
49
+
50
+ const GENERATES = { 木: '火', 火: '土', 土: '金', 金: '水', 水: '木' };
51
+ const OVERCOMES = { 木: '土', 土: '水', 水: '火', 火: '金', 金: '木' };
52
+
53
+ const LIUSHEN = ['青龙', '朱雀', '勾陈', '螣蛇', '白虎', '玄武'];
54
+ // 日干 -> 初爻所起六神
55
+ const LIUSHEN_START = { 甲: 0, 乙: 0, 丙: 1, 丁: 1, 戊: 2, 己: 3, 庚: 4, 辛: 4, 壬: 5, 癸: 5 };
56
+
57
+ function liuqin(palaceElement, branchElement) {
58
+ if (palaceElement === branchElement) return '兄弟';
59
+ if (GENERATES[branchElement] === palaceElement) return '父母';
60
+ if (GENERATES[palaceElement] === branchElement) return '子孙';
61
+ if (OVERCOMES[branchElement] === palaceElement) return '官鬼';
62
+ if (OVERCOMES[palaceElement] === branchElement) return '妻财';
63
+ return '?';
64
+ }
65
+
66
+ // 构建 (上卦, 下卦) -> 卦 的索引
67
+ const HEXAGRAM_INDEX = new Map();
68
+
69
+ function flip(bits, positions) {
70
+ const out = bits.slice();
71
+ for (const p of positions) out[p] = out[p] ? 0 : 1;
72
+ return out;
73
+ }
74
+
75
+ for (const [palace, names] of Object.entries(PALACE_NAMES)) {
76
+ const t = TRIGRAM_BITS[palace];
77
+ const variants = [
78
+ { lower: t, upper: t },
79
+ { lower: flip(t, [0]), upper: t },
80
+ { lower: flip(t, [0, 1]), upper: t },
81
+ { lower: flip(t, [0, 1, 2]), upper: t },
82
+ { lower: flip(t, [0, 1, 2]), upper: flip(t, [0]) },
83
+ { lower: flip(t, [0, 1, 2]), upper: flip(t, [0, 1]) },
84
+ { lower: flip(t, [0, 1, 2]), upper: flip(t, [1]) },
85
+ { lower: t, upper: flip(t, [1]) },
86
+ ];
87
+ variants.forEach((v, order) => {
88
+ const upper = TRIGRAM_BY_VALUE[bitsValue(v.upper)];
89
+ const lower = TRIGRAM_BY_VALUE[bitsValue(v.lower)];
90
+ HEXAGRAM_INDEX.set(`${upper}|${lower}`, {
91
+ name: names[order],
92
+ palace,
93
+ palaceElement: PALACE_ELEMENT[palace],
94
+ order,
95
+ upper,
96
+ lower,
97
+ lines: [...v.lower, ...v.upper], // b0 初爻
98
+ shi: SHI_POSITION[order],
99
+ ying: SHI_POSITION[order] > 3 ? SHI_POSITION[order] - 3 : SHI_POSITION[order] + 3,
100
+ });
101
+ });
102
+ }
103
+
104
+ export const HEXAGRAM_COUNT = HEXAGRAM_INDEX.size;
105
+
106
+ /** 由六爻(自下而上,1 = 阳)求卦 */
107
+ export function hexagramFromBits(bits) {
108
+ const upper = TRIGRAM_BY_VALUE[bitsValue(bits.slice(3))];
109
+ const lower = TRIGRAM_BY_VALUE[bitsValue(bits.slice(0, 3))];
110
+ return HEXAGRAM_INDEX.get(`${upper}|${lower}`);
111
+ }
112
+
113
+ /** 随机摇卦:三枚铜钱,背为阳(3),字为阴(2),合计 6/7/8/9 */
114
+ export function tossLines(random = Math.random) {
115
+ const lines = [];
116
+ for (let i = 0; i < 6; i++) {
117
+ let sum = 0;
118
+ for (let c = 0; c < 3; c++) sum += random() < 0.5 ? 3 : 2;
119
+ lines.push(sum); // 6 老阴 7 少阳 8 少阴 9 老阳
120
+ }
121
+ return lines;
122
+ }
123
+
124
+ function lineToBit(v) {
125
+ return v === 7 || v === 9 ? 1 : 0;
126
+ }
127
+
128
+ function isMoving(v) {
129
+ return v === 6 || v === 9;
130
+ }
131
+
132
+ /**
133
+ * 装卦
134
+ * @param {object} input { lines?: number[] (自下而上, 6/7/8/9), dayGanZhi?: {stem, branch}, question?: string }
135
+ */
136
+ export function buildLiuyao(input = {}) {
137
+ const rawLines = Array.isArray(input.lines) && input.lines.length === 6
138
+ ? input.lines.slice()
139
+ : tossLines(input.random);
140
+
141
+ const invalid = rawLines.filter((v) => ![6, 7, 8, 9].includes(v));
142
+ if (invalid.length) throw new Error(`爻值只能是 6/7/8/9,收到 ${JSON.stringify(rawLines)}`);
143
+
144
+ const bits = rawLines.map(lineToBit);
145
+ const ben = hexagramFromBits(bits);
146
+ const moving = rawLines.map((v, i) => (isMoving(v) ? i : -1)).filter((i) => i >= 0);
147
+ const bianBits = moving.length ? flip(bits, moving) : null;
148
+ const bian = bianBits ? hexagramFromBits(bianBits) : null;
149
+
150
+ const dayGanZhi = input.dayGanZhi ?? (() => {
151
+ const now = new Date();
152
+ const jdn = toJDN(now.getFullYear(), now.getMonth() + 1, now.getDate());
153
+ const g = ganzhiName(dayGanzhiIndex(jdn));
154
+ return { stem: g.stem, branch: g.branch, name: g.name };
155
+ })();
156
+
157
+ const liushenStart = LIUSHEN_START[dayGanZhi.stem] ?? 0;
158
+
159
+ // 旬空:由日柱所在的旬推两位空亡(甲子旬→戌亥、甲戌旬→申酉、甲申旬→午未、
160
+ // 甲午旬→辰巳、甲辰旬→寅卯、甲寅旬→子丑)。六爻断卦离不开它,之前没有输出。
161
+ // 公式统一放在 calendar.xunKongOf,三体系共用一份,避免各自演化后互相漂移。
162
+ const dayIdx = sexagenaryIndex(STEMS.indexOf(dayGanZhi.stem), BRANCHES.indexOf(dayGanZhi.branch));
163
+ const xun = dayIdx >= 0 ? Math.floor(dayIdx / 10) : -1;
164
+ const xunKong = xunKongOf(`${dayGanZhi.stem}${dayGanZhi.branch}`);
165
+ const xunShou = xun >= 0 ? ganzhiName(xun * 10).name : '';
166
+
167
+ function decorate(hex, movingSet) {
168
+ const rows = [];
169
+ for (let i = 0; i < 6; i++) {
170
+ const isInner = i < 3;
171
+ const trigram = isInner ? hex.lower : hex.upper;
172
+ const nj = NAJIA[trigram];
173
+ const branch = isInner ? nj.inner[i] : nj.outer[i - 3];
174
+ const gan = isInner ? nj.gan[0] : nj.gan[1];
175
+ const element = BRANCH_ELEMENT[branch];
176
+ const value = rawLines[i];
177
+ rows.push({
178
+ position: i + 1,
179
+ name: ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'][i],
180
+ yinYang: bits[i] ? '阳' : '阴',
181
+ moving: movingSet ? movingSet.has(i) : isMoving(value),
182
+ symbol: bits[i] ? '━━━━━' : '━━ ━━',
183
+ ganzhi: gan + branch,
184
+ stem: gan,
185
+ branch,
186
+ element,
187
+ liuqin: liuqin(hex.palaceElement, element),
188
+ liushen: LIUSHEN[(liushenStart + i) % 6],
189
+ });
190
+ }
191
+ return rows;
192
+ }
193
+
194
+ const benRows = decorate(ben, new Set(moving));
195
+ const movingSet = new Set(moving);
196
+ const bianRows = bian ? decorate(bian, new Set()) : null;
197
+ if (bianRows) {
198
+ bianRows.forEach((r, i) => {
199
+ r.changedFrom = movingSet.has(i) ? benRows[i].symbol : null;
200
+ });
201
+ }
202
+
203
+ return {
204
+ system: 'liuyao',
205
+ question: input.question ?? null,
206
+ cast: {
207
+ lines: rawLines,
208
+ notation: rawLines.map((v) => ({ 6: '老阴 ✕', 7: '少阳', 8: '少阴', 9: '老阳 ○' }[v])),
209
+ movingPositions: moving.map((i) => i + 1),
210
+ },
211
+ dayGanZhi: { name: dayGanZhi.name, stem: dayGanZhi.stem, branch: dayGanZhi.branch },
212
+ xunShou,
213
+ xunKong,
214
+ ben: {
215
+ name: ben.name,
216
+ upper: ben.upper,
217
+ lower: ben.lower,
218
+ palace: `${ben.palace}宫`,
219
+ palaceElement: ben.palaceElement,
220
+ shi: ben.shi,
221
+ ying: ben.ying,
222
+ lines: benRows,
223
+ },
224
+ bian: bian
225
+ ? {
226
+ name: bian.name,
227
+ upper: bian.upper,
228
+ lower: bian.lower,
229
+ palace: `${bian.palace}宫`,
230
+ palaceElement: bian.palaceElement,
231
+ shi: bian.shi,
232
+ ying: bian.ying,
233
+ lines: bianRows,
234
+ }
235
+ : null,
236
+ };
237
+ }
package/engine/lru.js ADDED
@@ -0,0 +1,69 @@
1
+ // 统一 LRU 缓存:上限 + 可选 TTL + 命中率计数。
2
+ //
3
+ // 原先四处缓存各自为政(节气瞬时 4096 FIFO、单年 32、三年窗口 8、地址 200+TTL),
4
+ // 淘汰策略与上限写法都不一样,也没法回答「到底有没有命中」。这里抽一个工厂,
5
+ // 各处只保留自己的上限与 TTL 参数,行为等价,但结构统一、可观测。
6
+
7
+ /**
8
+ * @param {{max?: number, ttlMs?: number, name?: string, now?: () => number}} [opts]
9
+ * max 最多保留多少条(超出按最久未使用淘汰)
10
+ * ttlMs >0 时,超过该毫秒数的条目视为未命中并删除
11
+ * now 取时函数(测试可注入;默认 Date.now,避免引擎里散落时间源)
12
+ */
13
+ export function createLru({ max = 64, ttlMs = 0, name = 'lru', now = () => Date.now() } = {}) {
14
+ const map = new Map(); // key -> { value, at }
15
+ const stats = { name, hits: 0, misses: 0, evictions: 0, expirations: 0 };
16
+
17
+ const expired = (entry) => ttlMs > 0 && now() - entry.at > ttlMs;
18
+
19
+ return {
20
+ name,
21
+ max,
22
+ ttlMs,
23
+ get(key) {
24
+ const entry = map.get(key);
25
+ if (!entry || expired(entry)) {
26
+ if (entry) { map.delete(key); stats.expirations += 1; }
27
+ stats.misses += 1;
28
+ return undefined;
29
+ }
30
+ // 命中后移到队尾,维持「最久未使用在队首」
31
+ map.delete(key);
32
+ map.set(key, entry);
33
+ stats.hits += 1;
34
+ return entry.value;
35
+ },
36
+ set(key, value) {
37
+ if (map.has(key)) map.delete(key);
38
+ map.set(key, { value, at: now() });
39
+ while (map.size > max) {
40
+ map.delete(map.keys().next().value);
41
+ stats.evictions += 1;
42
+ }
43
+ return value;
44
+ },
45
+ has(key) {
46
+ const entry = map.get(key);
47
+ if (!entry) return false;
48
+ if (expired(entry)) { map.delete(key); stats.expirations += 1; return false; }
49
+ return true;
50
+ },
51
+ delete(key) { return map.delete(key); },
52
+ clear() { map.clear(); },
53
+ get size() { return map.size; },
54
+ /** 快照:命中率在调用时按当前计数算,便于渲染一行指标 */
55
+ stats() {
56
+ const total = stats.hits + stats.misses;
57
+ return {
58
+ name,
59
+ size: map.size,
60
+ max,
61
+ hits: stats.hits,
62
+ misses: stats.misses,
63
+ evictions: stats.evictions,
64
+ expirations: stats.expirations,
65
+ hitRate: total ? stats.hits / total : 0,
66
+ };
67
+ },
68
+ };
69
+ }