dsh-galatea 0.4.2 → 0.4.4
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/README.md +54 -9
- package/index.js +36 -2
- package/lib/beat.js +17 -3
- package/lib/heartbeat-runner.js +26 -2
- package/lib/heartbeat.js +24 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
| 块 | 状态 |
|
|
14
14
|
|---|---|
|
|
15
15
|
| 能被 DSH 装起来 | ✅ 实测通过(dsh 0.2.2-rc.3) |
|
|
16
|
-
| 发布到 npm | ✅ 0.
|
|
16
|
+
| 发布到 npm | ✅ 0.4.4(GitHub Actions 自动发,不需要 token) |
|
|
17
17
|
| 记忆:读写 Markdown | ✅ 15 条测试 |
|
|
18
18
|
| 记忆:注入索引 | ✅ **已核实内容真的进了上下文**(不是在会话日志里看到「接上了没告警」) |
|
|
19
19
|
| 记忆:`remember` / `recall` | ✅ 23 条测试 |
|
|
@@ -27,10 +27,13 @@
|
|
|
27
27
|
| 心跳:沉默可解释 | ✅ `galatea_beat` 会把最近几次的决定翻成人话 |
|
|
28
28
|
| 心跳:听得见你说话 | ⚠️ 接了 `agent/pre-step`,**但那条事件有没有真的触发,没验过** |
|
|
29
29
|
| 设置页可改的字段 | ⚠️ 声明被宿主接受,**表单没在 GUI 里看过** |
|
|
30
|
-
|
|
|
30
|
+
| 心跳:定时器自己跑 | ✅ 实测:每 60 秒一次,账本持续累积,跨进程存活 |
|
|
31
|
+
| 心跳:自动开口(真的送出去) | ⬜ 未做 —— **这一版只算不发,但算得对了** |
|
|
31
32
|
|
|
32
|
-
> ⚠️ **它现在还不会自己开口。**
|
|
33
|
-
>
|
|
33
|
+
> ⚠️ **它现在还不会自己开口。** 定时器接上了,判定链路也完整了
|
|
34
|
+
> (会算出「该不该说、对谁说、拿哪条当由头」),
|
|
35
|
+
> 但把话真的送出去那一步还没接。
|
|
36
|
+
> 别把「账本在长」读成「它已经在主动找你了」。
|
|
34
37
|
|
|
35
38
|
关于上面那条 ⚠️:`noteUserMessage` 接在 `agent/pre-step` 上,用来记「你刚说过话」
|
|
36
39
|
(cooldown 要用它)。**本机验证只到「加载不崩」**——那个测试 profile 里没有会话,
|
|
@@ -60,7 +63,7 @@
|
|
|
60
63
|
|
|
61
64
|
## 设置(不用改代码)
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
七个字段声明为 `.volatile()`,所以 DSH 的设置页会照着 schema 自动生成表单,
|
|
64
67
|
改完**不重启插件**:
|
|
65
68
|
|
|
66
69
|
| 字段 | 说明 |
|
|
@@ -70,9 +73,13 @@
|
|
|
70
73
|
| `minGapMinutes` | 两次心跳最少隔多久 |
|
|
71
74
|
| `dice` | 没有由头时的开口概率。0 = 只在有素材时说 |
|
|
72
75
|
| `quietRounds` | 连续多少轮没开口就强制说一次。0 = 关掉这个保底 |
|
|
76
|
+
| `autoBeatMinutes` | 每隔多少分钟自己跑一次。0 = 关(默认)。见下面那条 |
|
|
73
77
|
|
|
74
|
-
|
|
78
|
+
四注意:
|
|
75
79
|
|
|
80
|
+
- **`autoBeatMinutes` 小于 `minGapMinutes` 时,实际频率由后者决定。** 比如设 1 分钟、
|
|
81
|
+
最小间隔 20 分钟,就是每 20 分钟才真正判定一次,中间那些跳只记一条 `tooSoon`。
|
|
82
|
+
这不是没生效,是间隔在压着它 —— 装的时候会把这句话写进 marker 文件。
|
|
76
83
|
- **「连续几轮没开口就强制一次」和「最少间隔」是耦合的。** 心跳间隔设得比保底所需的时间还短,保底永远不会触发。
|
|
77
84
|
- **把 `quietRounds` 关掉,它可能安静到让你以为坏了。** 默认闭嘴需要一条下限,不然沉默和故障长得一样。
|
|
78
85
|
- **安静时段优先于保底,但保底不会被清零。** 两者撞上时,安静时段赢:那段时间它不说话。可是「连续没开口」的次数照常往上加,所以走出安静时段后的第一次心跳就会走保底 —— 保底只是被推迟,没有被悄悄关掉。
|
|
@@ -118,6 +125,43 @@
|
|
|
118
125
|
|
|
119
126
|
门控可以自动决定,**内容不能自动生成**。空话比不说更糟,它消耗的是「它真的在」那点信用。
|
|
120
127
|
|
|
128
|
+
## 一次教训:心跳把自己的基准刷掉了
|
|
129
|
+
|
|
130
|
+
0.4.1 的定时器确实在跑,账本也确实在长,但里面永远是同一句:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
16:36:15 tooSoon — 距上一轮心跳 1.0 分钟,不到 20
|
|
134
|
+
16:37:15 tooSoon — 距上一轮心跳 1.0 分钟,不到 20
|
|
135
|
+
16:38:15 tooSoon — 距上一轮心跳 1.0 分钟,不到 20
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
一个字段干了两件事:`lastBeatAt` 既是「上次跑过」,又是「上次判定的基准」。
|
|
139
|
+
于是每一跳都把它刷成「现在」⟹ 下一跳读到的间隔永远等于定时器间隔
|
|
140
|
+
⟹ 只要间隔小于 `minGapMinutes`,就永远 `tooSoon`,永远说不了第一句话。
|
|
141
|
+
|
|
142
|
+
**它看起来完全正常**:进程活着、定时器在响、账本在长。
|
|
143
|
+
唯一不对的,是它永远不会开口。
|
|
144
|
+
|
|
145
|
+
现在拆成两个:
|
|
146
|
+
|
|
147
|
+
| 字段 | 谁写 | 干什么 |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| `lastTickAt` | 每一跳都写 | 只回答「定时器还活着吗」 |
|
|
150
|
+
| `lastBeatAt` | 只有**真的走到判定**的那一跳才写 | `minGapMinutes` 的基准 |
|
|
151
|
+
|
|
152
|
+
判据不在调用方,在 `decide()` 自己返回的 `touchBeat` 里 ——
|
|
153
|
+
两处各写一份必然漂移,而漂移的方向永远是「改了一处忘了另一处」。
|
|
154
|
+
|
|
155
|
+
**「被挡住」不算一次心跳。** 安静时段、刚聊过、间隔不够,都只是时钟走了一格,
|
|
156
|
+
不是一次「说不说」的机会。所以它们也不该让 `quietStreak` 往上加 ——
|
|
157
|
+
否则 1 分钟一跳的定时器会在 20 分钟内把计数顶到 20,
|
|
158
|
+
第一次真正判定就撞上下限保护,变成「每 20 分钟强制说一句」。
|
|
159
|
+
一个 bug 修完,第二个 bug 就躲在同一个地方。
|
|
160
|
+
|
|
161
|
+
这一条不是读代码读出来的。外面的人真机跑了七分钟,看到七条一模一样的
|
|
162
|
+
`tooSoon`,才把它揪出来 —— **单元测试全绿,因为测试都是按「一分钟一跳不合法」
|
|
163
|
+
去构造的**,而没有人试过「一小时里每一分钟都跳一次」。
|
|
164
|
+
|
|
121
165
|
## 一次教训:模块边界
|
|
122
166
|
|
|
123
167
|
0.3.0 / 0.3.5 / 0.3.6 连着三次,坏的地方都在**两个文件交接处**:
|
|
@@ -147,13 +191,14 @@ $DSH_HOME/galatea/heartbeat-state.json # 心跳状态账本
|
|
|
147
191
|
|
|
148
192
|
文件名就是 id。日期在前,所以目录列表天然按时间排。正文第一行是标题。
|
|
149
193
|
|
|
150
|
-
|
|
194
|
+
状态账本记的是:上一次**真正判定**是什么时候(`lastBeatAt`)、定时器上一次跑是什么时候
|
|
195
|
+
(`lastTickAt`)、上次开口是什么时候、连续几轮**判定**没说话、最近的决定。
|
|
151
196
|
它必须落盘——不落盘的话进程一重启计数就归零,下限保护立刻失效。
|
|
152
197
|
|
|
153
198
|
## 本地开发
|
|
154
199
|
|
|
155
200
|
```bash
|
|
156
|
-
npm test #
|
|
201
|
+
npm test # 98 条,不依赖 DSH,纯 node
|
|
157
202
|
```
|
|
158
203
|
|
|
159
204
|
装了之后验证它真的加载了,只看一个东西:
|
|
@@ -174,7 +219,7 @@ dsh plugin add dsh-galatea
|
|
|
174
219
|
或者在 profile 的 `package.json` 里手写:
|
|
175
220
|
|
|
176
221
|
```json
|
|
177
|
-
"dependencies": { "dsh-galatea": "0.
|
|
222
|
+
"dependencies": { "dsh-galatea": "0.4.4" }
|
|
178
223
|
```
|
|
179
224
|
|
|
180
225
|
**版本号钉死,不要写 `^`。** 同一个版本号指向两个不同的包,以后就说不清你装的是哪一份。
|
package/index.js
CHANGED
|
@@ -16,7 +16,8 @@ import z from '@deepseek-ai/schemastery';
|
|
|
16
16
|
import { renderIndex, dataRoot, listEntries } from './lib/store.js';
|
|
17
17
|
import { remember, recall } from './lib/tools.js';
|
|
18
18
|
import { makeBeatTool, snapshotAgents } from './lib/beat-tool.js';
|
|
19
|
-
import { loadState, noteUserMessage,
|
|
19
|
+
import { commitDecision, loadState, noteUserMessage, saveState, stateFile } from './lib/heartbeat-runner.js';
|
|
20
|
+
import { planBeat } from './lib/beat.js';
|
|
20
21
|
import { DEFAULTS } from './lib/heartbeat.js';
|
|
21
22
|
|
|
22
23
|
/** Cordis 插件名。 */
|
|
@@ -246,13 +247,46 @@ export function apply(ctx, config) {
|
|
|
246
247
|
// 由当前 fiber 管着,宿主热重载时不会叠出第二个定时器。
|
|
247
248
|
ctx.interval(() => {
|
|
248
249
|
try {
|
|
249
|
-
|
|
250
|
+
const at = new Date();
|
|
251
|
+
const state = loadState(stateFile());
|
|
252
|
+
// 走和手动档**同一条**链路(planBeat)。
|
|
253
|
+
//
|
|
254
|
+
// 上一版这里是 `tick({})`,它直接调门控、只带一个 state:
|
|
255
|
+
// 没有候选会话、没有素材、hasHardMaterial 永远 false。
|
|
256
|
+
// 于是自动档只能掷色子,手动档永远走硬素材 —— 两条路都叫心跳,
|
|
257
|
+
// 行为却正好相反,而两边各自的账本都「看起来在动」。
|
|
258
|
+
//
|
|
259
|
+
// 复用 planBeat 的代价是要拿 agents 和 entries,好处是:
|
|
260
|
+
// 自动档终于能算出「本来想说什么、打算对谁说」,而且
|
|
261
|
+
// 以后改目标选择/素材选择时,只需要改一处。
|
|
262
|
+
const r = planBeat({
|
|
263
|
+
now: at,
|
|
264
|
+
state,
|
|
265
|
+
candidates: snapshotAgents(ctx.agents),
|
|
266
|
+
entries: listEntries(),
|
|
267
|
+
config: liveConfig(raw),
|
|
268
|
+
});
|
|
269
|
+
saveState(commitDecision(state, r, at), stateFile());
|
|
250
270
|
} catch (err) {
|
|
251
271
|
// 定时器抛出去会打断整个宿主,所以在这里兜住,但要说出来
|
|
252
272
|
console.warn('[galatea] 自动心跳失败:', err && err.message);
|
|
253
273
|
}
|
|
254
274
|
}, ms);
|
|
255
275
|
console.log(`[galatea] 自动心跳已开:每 ${everyMin} 分钟跑一次(只算不发)`);
|
|
276
|
+
|
|
277
|
+
// 一个很自然但会误会的组合:自动心跳间隔 < 最小间隔。
|
|
278
|
+
// 它**不是错误** —— minGap 会把节奏压回 20 分钟一次。
|
|
279
|
+
// 但用户以为自己设的是 autoBeatMinutes,所以得把实际生效的那个数说出来。
|
|
280
|
+
const gapMin = Number(cfg.minGapMinutes ?? DEFAULTS.minGapMinutes);
|
|
281
|
+
if (Number.isFinite(gapMin) && gapMin > 0 && everyMin < gapMin) {
|
|
282
|
+
const note = `[galatea] 自动心跳设的是每 ${everyMin} 分钟一次,但最小间隔是 ${gapMin} 分钟 —— 实际每 ${gapMin} 分钟才真正判定一次(不是没生效,是被间隔压住了)。`;
|
|
283
|
+
console.warn(note);
|
|
284
|
+
try {
|
|
285
|
+
appendFileSync(marker, ` ${note}\n`);
|
|
286
|
+
} catch {
|
|
287
|
+
// 记录失败不该连累插件
|
|
288
|
+
}
|
|
289
|
+
}
|
|
256
290
|
} else {
|
|
257
291
|
// 用户明确要求了、却被静默拒绝 —— 这一次不能只在 console 里说。
|
|
258
292
|
// 真机上 console.warn 用户看不见,表现是「配了完全不生效,也没有任何提示」。
|
package/lib/beat.js
CHANGED
|
@@ -56,14 +56,24 @@ export function planBeat(input) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// ── ③ 现在该不该说 ──────────────────────────────────────
|
|
59
|
-
//
|
|
60
|
-
//
|
|
59
|
+
//
|
|
60
|
+
// `hasHardMaterial` 这一版**没有判据**,所以默认 false。
|
|
61
|
+
//
|
|
62
|
+
// 原来这里是写死的 true,于是手动档和自动档各走一个极端:
|
|
63
|
+
// 手动档(走 planBeat)—— 只要有素材就必定开口,色子永远掷不到;
|
|
64
|
+
// 自动档(走 tick) —— 永远 false,永远只掷色子,看不见素材。
|
|
65
|
+
// 两条路都自称「心跳」,行为却正好相反。
|
|
66
|
+
//
|
|
67
|
+
// 「有素材」只代表有东西可说,不代表现在非说不可 —— 把两件事合成一件,
|
|
68
|
+
// 结果是每有一条没读过的记忆就要说一次,比闹钟还吵。
|
|
69
|
+
//
|
|
70
|
+
// 将来接上真正硬的东西(承诺到期、上次没说完的),由调用方传 true。
|
|
61
71
|
const decision = decide({
|
|
62
72
|
now,
|
|
63
73
|
lastBeatAt: state.lastBeatAt ?? null,
|
|
64
74
|
lastUserAt: state.lastUserAt ?? null,
|
|
65
75
|
lastSpokeAt: state.lastSpokeAt ?? null,
|
|
66
|
-
hasHardMaterial: true,
|
|
76
|
+
hasHardMaterial: input.hasHardMaterial === true,
|
|
67
77
|
quietStreak: state.quietStreak ?? 0,
|
|
68
78
|
config: cfg,
|
|
69
79
|
random: input.random,
|
|
@@ -78,6 +88,7 @@ export function planBeat(input) {
|
|
|
78
88
|
previous: target.previous,
|
|
79
89
|
materialId: material.entry.id,
|
|
80
90
|
detail: decision.detail,
|
|
91
|
+
touchBeat: decision.touchBeat,
|
|
81
92
|
};
|
|
82
93
|
|
|
83
94
|
function no(reason, detail, extra) {
|
|
@@ -90,6 +101,9 @@ export function planBeat(input) {
|
|
|
90
101
|
previous: extra.previous,
|
|
91
102
|
materialId: null,
|
|
92
103
|
detail,
|
|
104
|
+
// 没走到 decide 的,都不算一次心跳 —— 那是「没人可说话 / 没得说」,
|
|
105
|
+
// 不是「判过了,结论是不说」。这两种要分开,否则 lastBeatAt 会被空转刷掉。
|
|
106
|
+
touchBeat: false,
|
|
93
107
|
};
|
|
94
108
|
}
|
|
95
109
|
}
|
package/lib/heartbeat-runner.js
CHANGED
|
@@ -19,7 +19,16 @@ export function stateFile() {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export const EMPTY_STATE = {
|
|
22
|
+
/**
|
|
23
|
+
* 上一次**真正走到判定**的时刻。minGap 用的就是它。
|
|
24
|
+
*
|
|
25
|
+
* 它和 lastTickAt 是两个东西,混成一个会出死锁:
|
|
26
|
+
* 定时器每跳一次都刷新它 ⟹ 每次读到的 gap 都等于定时器间隔
|
|
27
|
+
* ⟹ 只要间隔小于 minGapMinutes,就永远 tooSoon,永远说不了话。
|
|
28
|
+
*/
|
|
22
29
|
lastBeatAt: null,
|
|
30
|
+
/** 上一次**定时器跑过**的时刻。每次跳都写,诊断用 —— 它不参与任何判据。 */
|
|
31
|
+
lastTickAt: null,
|
|
23
32
|
lastSpokeAt: null,
|
|
24
33
|
lastUserAt: null,
|
|
25
34
|
quietStreak: 0,
|
|
@@ -68,10 +77,25 @@ export function commitDecision(state, decision, now = new Date(), opts = {}) {
|
|
|
68
77
|
};
|
|
69
78
|
const next = {
|
|
70
79
|
...state,
|
|
71
|
-
|
|
72
|
-
quietStreak: decision.speak ? 0 : (state.quietStreak ?? 0) + 1,
|
|
80
|
+
lastTickAt: at,
|
|
73
81
|
decisions: [...(state.decisions ?? []), entry].slice(-(opts.keepDecisions ?? 50)),
|
|
74
82
|
};
|
|
83
|
+
// 「被 minGap / 安静时段 / 冷却挡住」的那一跳,只是时钟走了一格,
|
|
84
|
+
// 不说明有过一次「说不说」的机会 —— 所以下面两样都不该动,理由各一条:
|
|
85
|
+
//
|
|
86
|
+
// ① lastBeatAt:动了它,minGap 的基准就被自己刷新,于是永远 tooSoon。
|
|
87
|
+
// ② quietStreak:动了它,1 分钟一跳的定时器 20 分钟就能把计数顶到 20,
|
|
88
|
+
// 于是**第一次**真正判定就撞上下限保护 —— 用户以为设的是「偶尔说句」,
|
|
89
|
+
// 实际得到的是「每 20 分钟强制说一句」。
|
|
90
|
+
//
|
|
91
|
+
// 判据由 decide() 自己给(touchBeat),这里不猜 reason ——
|
|
92
|
+
// 两处各写一份必然漂移,而漂移的方向永远是「改了一处忘了另一处」。
|
|
93
|
+
//
|
|
94
|
+
// 默认 true:老调用点(不带这个字段的)行为不变,不会因为升级而卡住。
|
|
95
|
+
if (decision.touchBeat !== false) {
|
|
96
|
+
next.lastBeatAt = at;
|
|
97
|
+
next.quietStreak = decision.speak ? 0 : (state.quietStreak ?? 0) + 1;
|
|
98
|
+
}
|
|
75
99
|
if (decision.speak) next.lastSpokeAt = at;
|
|
76
100
|
return next;
|
|
77
101
|
}
|
package/lib/heartbeat.js
CHANGED
|
@@ -99,7 +99,21 @@ export function inQuiet(minuteOfDay, quiet) {
|
|
|
99
99
|
* @param {object} [input.config] 覆盖 DEFAULTS
|
|
100
100
|
* @param {() => number} [input.random] 注入随机源,测试用
|
|
101
101
|
*
|
|
102
|
-
* @returns {{ speak: boolean, reason: string, detail: string }}
|
|
102
|
+
* @returns {{ speak: boolean, reason: string, detail: string, touchBeat: boolean }}
|
|
103
|
+
*
|
|
104
|
+
* `touchBeat` 是这个函数对调用方的第二条输出,和 speak 一样重要:
|
|
105
|
+
* 它回答「这一轮算不算一次心跳」。
|
|
106
|
+
*
|
|
107
|
+
* 被 quiet / tooSoon / cooldown 挡下的**不算** —— 那三种都是还没轮到判定,
|
|
108
|
+
* 只是定时器空跑了一次。如果把它们也记成心跳,minGap 的基准会被自己刷新:
|
|
109
|
+
*
|
|
110
|
+
* 定时器每 1 分钟一跳 → 每次读到的 gap 都是 1 → 永远小于 minGap(20)
|
|
111
|
+
* → 永远 tooSoon → 永远走不到后面的分支
|
|
112
|
+
*
|
|
113
|
+
* 这个死锁长得很正常:账本里一直有新记录,定时器确实在动,
|
|
114
|
+
* 唯一不对的地方是**它永远不会说出第一句话**。外面的人真机跑了一轮才看出来。
|
|
115
|
+
*
|
|
116
|
+
* tooSoon 尤其不能算:它就是「距上次判定不够久」这件事本身。
|
|
103
117
|
*/
|
|
104
118
|
export function decide(input) {
|
|
105
119
|
const cfg = { ...DEFAULTS, ...(input.config ?? {}) };
|
|
@@ -108,7 +122,7 @@ export function decide(input) {
|
|
|
108
122
|
|
|
109
123
|
// ── ① 安静时段:硬墙,先挡 ──────────────────────────────
|
|
110
124
|
if (inQuiet(minutesOfDay(now), cfg.quiet)) {
|
|
111
|
-
return { speak: false, reason: 'quiet', detail: '落在用户设的安静时段里' };
|
|
125
|
+
return { speak: false, reason: 'quiet', detail: '落在用户设的安静时段里', touchBeat: false };
|
|
112
126
|
}
|
|
113
127
|
|
|
114
128
|
// ── ② 间隔:定时器抽风或上一轮太近,别叠 ────────────────
|
|
@@ -119,7 +133,8 @@ export function decide(input) {
|
|
|
119
133
|
return {
|
|
120
134
|
speak: false,
|
|
121
135
|
reason: 'tooSoon',
|
|
122
|
-
detail:
|
|
136
|
+
detail: `距上一次真正判定 ${gap.toFixed(1)} 分钟,不到 ${cfg.minGapMinutes}`,
|
|
137
|
+
touchBeat: false,
|
|
123
138
|
};
|
|
124
139
|
}
|
|
125
140
|
}
|
|
@@ -133,6 +148,7 @@ export function decide(input) {
|
|
|
133
148
|
speak: false,
|
|
134
149
|
reason: 'cooldown',
|
|
135
150
|
detail: `距用户上一条消息 ${since.toFixed(1)} 分钟,不到 ${cfg.cooldownMinutes}`,
|
|
151
|
+
touchBeat: false,
|
|
136
152
|
};
|
|
137
153
|
}
|
|
138
154
|
}
|
|
@@ -148,10 +164,11 @@ export function decide(input) {
|
|
|
148
164
|
speak: false,
|
|
149
165
|
reason: 'cooldown',
|
|
150
166
|
detail: `距上次主动开口 ${since.toFixed(1)} 分钟,不到 ${cfg.cooldownMinutes}`,
|
|
167
|
+
touchBeat: false,
|
|
151
168
|
};
|
|
152
169
|
}
|
|
153
170
|
}
|
|
154
|
-
return { speak: true, reason: 'hard', detail: '有具体的事要说' };
|
|
171
|
+
return { speak: true, reason: 'hard', detail: '有具体的事要说', touchBeat: true };
|
|
155
172
|
}
|
|
156
173
|
|
|
157
174
|
// ── ⑤ 下限保护:太久没开口,强制允许一次 ────────────────
|
|
@@ -160,18 +177,20 @@ export function decide(input) {
|
|
|
160
177
|
speak: true,
|
|
161
178
|
reason: 'floor',
|
|
162
179
|
detail: `已连续 ${input.quietStreak} 轮没开口,触到下限保护`,
|
|
180
|
+
touchBeat: true,
|
|
163
181
|
};
|
|
164
182
|
}
|
|
165
183
|
|
|
166
184
|
// ── ⑥ 弱素材:掷色子 ────────────────────────────────────
|
|
167
185
|
const r = random();
|
|
168
186
|
if (r < cfg.dice) {
|
|
169
|
-
return { speak: true, reason: 'dice', detail: `色子 ${r.toFixed(3)} < ${cfg.dice}
|
|
187
|
+
return { speak: true, reason: 'dice', detail: `色子 ${r.toFixed(3)} < ${cfg.dice}`, touchBeat: true };
|
|
170
188
|
}
|
|
171
189
|
return {
|
|
172
190
|
speak: false,
|
|
173
191
|
reason: 'noMaterial',
|
|
174
192
|
detail: `没硬素材,色子 ${r.toFixed(3)} ≥ ${cfg.dice}`,
|
|
193
|
+
touchBeat: true,
|
|
175
194
|
};
|
|
176
195
|
}
|
|
177
196
|
|