dsh-plugin-feihualing 1.0.0 → 1.1.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/README.md +33 -5
- package/cordis.yml +7 -3
- package/lib/client.js +1464 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +109 -135
- package/lib/index.js +1156 -822
- package/package.json +19 -4
- package/lib/index.js.map +0 -1
package/lib/index.js
CHANGED
|
@@ -1,851 +1,1185 @@
|
|
|
1
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
1
5
|
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
6
|
+
* 规范化一行文本:去除空白与常见中英文标点,仅保留汉字/字母/数字。
|
|
7
|
+
* 用于关键词匹配与诗句去重比较。
|
|
8
|
+
*/
|
|
9
|
+
function normalizeLine(raw) {
|
|
10
|
+
return raw.replace(/[\s,。!?、;:""''()《》〈〉【】\[\]·,.!?;:'"()\-—…]+/gu, "");
|
|
11
|
+
}
|
|
12
|
+
/** 去掉文本首尾空白(用于展示时压缩用户输入的换行/空格)。 */
|
|
13
|
+
function trimDisplay(raw) {
|
|
14
|
+
return raw.trim().replace(/\s+/gu, " ").slice(0, 48);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 诗句判定核心(纯函数,Host/Client 共用)。
|
|
18
|
+
* 判定顺序与对话版完全一致:含令字门槛 → 长度 → 去重 → 古法位置。
|
|
19
|
+
*/
|
|
20
|
+
function judgePoemAttempt(input) {
|
|
21
|
+
const normalized = normalizeLine(input.rawText);
|
|
22
|
+
const display = trimDisplay(input.rawText);
|
|
23
|
+
if (!normalized.includes(input.lingzi)) return {
|
|
24
|
+
kind: "notPoem",
|
|
25
|
+
normalized,
|
|
26
|
+
display,
|
|
27
|
+
length: normalized.length
|
|
28
|
+
};
|
|
29
|
+
if (normalized.length < 4 || normalized.length > 48) return {
|
|
30
|
+
kind: "length",
|
|
31
|
+
normalized,
|
|
32
|
+
display,
|
|
33
|
+
length: normalized.length
|
|
34
|
+
};
|
|
35
|
+
if (input.usedNormalized.includes(normalized)) return {
|
|
36
|
+
kind: "duplicate",
|
|
37
|
+
normalized,
|
|
38
|
+
display,
|
|
39
|
+
length: normalized.length
|
|
40
|
+
};
|
|
41
|
+
if (input.mode === "ancient") {
|
|
42
|
+
const pos = input.requiredPosition;
|
|
43
|
+
if (normalized.length < pos) return {
|
|
44
|
+
kind: "badPosition",
|
|
45
|
+
badPosReason: "tooShort",
|
|
46
|
+
requiredPosition: pos,
|
|
47
|
+
normalized,
|
|
48
|
+
display,
|
|
49
|
+
length: normalized.length
|
|
50
|
+
};
|
|
51
|
+
if (normalized[pos - 1] !== input.lingzi) return {
|
|
52
|
+
kind: "badPosition",
|
|
53
|
+
badPosReason: "mismatch",
|
|
54
|
+
requiredPosition: pos,
|
|
55
|
+
normalized,
|
|
56
|
+
display,
|
|
57
|
+
length: normalized.length
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
kind: "valid",
|
|
62
|
+
normalized,
|
|
63
|
+
display,
|
|
64
|
+
length: normalized.length
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/shared/poems.ts
|
|
69
|
+
/**
|
|
70
|
+
* 共享诗句库(Host 与 Client 两半共同使用;纯数据,零平台依赖)。
|
|
71
|
+
* 用途:Host —— 提示(hint)与令字抽取;Client —— 内置 AI 对手出句与提示。
|
|
72
|
+
* 判定本身不依赖本库(只查“含令字 + 未重复 + 古法位置”),因此诗句库的
|
|
73
|
+
* 覆盖面只影响“对手出句 / 提示”的花样,不影响规则正确性。
|
|
74
|
+
* 全部为公共领域经典诗句(唐/宋/元/清为主,个别汉魏、民歌与古乐府)。
|
|
75
|
+
* @module dsh-plugin-feihualing/shared/poems
|
|
76
|
+
*/
|
|
77
|
+
/** 令字 → 含该令字的经典诗句(规范化前原文,展示与去重用;每句必含令字)。 */
|
|
78
|
+
const POEM_BANK = Object.freeze({
|
|
79
|
+
花: Object.freeze([
|
|
80
|
+
"花间一壶酒",
|
|
81
|
+
"感时花溅泪",
|
|
82
|
+
"人面桃花相映红",
|
|
83
|
+
"霜叶红于二月花",
|
|
84
|
+
"花落知多少",
|
|
85
|
+
"春城无处不飞花",
|
|
86
|
+
"不是花中偏爱菊",
|
|
87
|
+
"黄四娘家花满蹊",
|
|
88
|
+
"竹外桃花三两枝",
|
|
89
|
+
"映日荷花别样红",
|
|
90
|
+
"花重锦官城",
|
|
91
|
+
"乱花渐欲迷人眼"
|
|
92
|
+
]),
|
|
93
|
+
月: Object.freeze([
|
|
94
|
+
"床前明月光",
|
|
95
|
+
"举头望明月",
|
|
96
|
+
"海上生明月",
|
|
97
|
+
"月落乌啼霜满天",
|
|
98
|
+
"月是故乡明",
|
|
99
|
+
"明月几时有",
|
|
100
|
+
"二十四桥明月夜",
|
|
101
|
+
"月上柳梢头",
|
|
102
|
+
"露似真珠月似弓",
|
|
103
|
+
"月黑雁飞高",
|
|
104
|
+
"深林人不知,明月来相照",
|
|
105
|
+
"明月松间照",
|
|
106
|
+
"江清月近人",
|
|
107
|
+
"海上明月共潮生",
|
|
108
|
+
"杨柳岸,晓风残月",
|
|
109
|
+
"月有阴晴圆缺"
|
|
110
|
+
]),
|
|
111
|
+
风: Object.freeze([
|
|
112
|
+
"夜来风雨声",
|
|
113
|
+
"春风又绿江南岸",
|
|
114
|
+
"风吹草低见牛羊",
|
|
115
|
+
"二月春风似剪刀",
|
|
116
|
+
"春风不度玉门关",
|
|
117
|
+
"长风破浪会有时",
|
|
118
|
+
"随风潜入夜",
|
|
119
|
+
"野火烧不尽,春风吹又生",
|
|
120
|
+
"大风起兮云飞扬",
|
|
121
|
+
"风萧萧兮易水寒",
|
|
122
|
+
"昨夜西风凋碧树",
|
|
123
|
+
"吹面不寒杨柳风",
|
|
124
|
+
"古道西风瘦马",
|
|
125
|
+
"北风卷地白草折"
|
|
126
|
+
]),
|
|
127
|
+
雪: Object.freeze([
|
|
128
|
+
"窗含西岭千秋雪",
|
|
129
|
+
"独钓寒江雪",
|
|
130
|
+
"柴门闻犬吠,风雪夜归人",
|
|
131
|
+
"北风吹雁雪纷纷",
|
|
132
|
+
"雪上空留马行处",
|
|
133
|
+
"北风卷地白草折,胡天八月即飞雪",
|
|
134
|
+
"遥知不是雪,为有暗香来",
|
|
135
|
+
"欲渡黄河冰塞川,将登太行雪满山",
|
|
136
|
+
"燕山雪花大如席",
|
|
137
|
+
"大雪满弓刀",
|
|
138
|
+
"青海长云暗雪山"
|
|
139
|
+
]),
|
|
140
|
+
山: Object.freeze([
|
|
141
|
+
"白日依山尽",
|
|
142
|
+
"山重水复疑无路",
|
|
143
|
+
"轻舟已过万重山",
|
|
144
|
+
"空山不见人",
|
|
145
|
+
"千山鸟飞绝",
|
|
146
|
+
"会当凌绝顶,一览众山小",
|
|
147
|
+
"相看两不厌,只有敬亭山",
|
|
148
|
+
"远上寒山石径斜",
|
|
149
|
+
"两岸青山相对出",
|
|
150
|
+
"国破山河在",
|
|
151
|
+
"青山遮不住",
|
|
152
|
+
"山外青山楼外楼",
|
|
153
|
+
"只在此山中,云深不知处"
|
|
154
|
+
]),
|
|
155
|
+
水: Object.freeze([
|
|
156
|
+
"抽刀断水水更流",
|
|
157
|
+
"桃花潭水深千尺",
|
|
158
|
+
"一江春水向东流",
|
|
159
|
+
"行到水穷处",
|
|
160
|
+
"春来江水绿如蓝",
|
|
161
|
+
"白毛浮绿水",
|
|
162
|
+
"水光潋滟晴方好",
|
|
163
|
+
"竹外桃花三两枝,春江水暖鸭先知",
|
|
164
|
+
"曾经沧海难为水",
|
|
165
|
+
"山重水复疑无路,柳暗花明又一村",
|
|
166
|
+
"问君能有几多愁,恰似一江春水向东流",
|
|
167
|
+
"流水落花春去也"
|
|
168
|
+
]),
|
|
169
|
+
云: Object.freeze([
|
|
170
|
+
"黄河远上白云间",
|
|
171
|
+
"只在此山中,云深不知处",
|
|
172
|
+
"白云生处有人家",
|
|
173
|
+
"行到水穷处,坐看云起时",
|
|
174
|
+
"众鸟高飞尽,孤云独去闲",
|
|
175
|
+
"黑云压城城欲摧",
|
|
176
|
+
"白云千载空悠悠",
|
|
177
|
+
"朝辞白帝彩云间",
|
|
178
|
+
"云想衣裳花想容",
|
|
179
|
+
"荡胸生层云",
|
|
180
|
+
"青海长云暗雪山",
|
|
181
|
+
"浮云游子意"
|
|
182
|
+
]),
|
|
183
|
+
雨: Object.freeze([
|
|
184
|
+
"清明时节雨纷纷",
|
|
185
|
+
"好雨知时节",
|
|
186
|
+
"天街小雨润如酥",
|
|
187
|
+
"巴山夜雨涨秋池",
|
|
188
|
+
"山色空蒙雨亦奇",
|
|
189
|
+
"夜阑卧听风吹雨",
|
|
190
|
+
"渭城朝雨浥轻尘",
|
|
191
|
+
"寒雨连江夜入吴",
|
|
192
|
+
"斜风细雨不须归",
|
|
193
|
+
"沾衣欲湿杏花雨",
|
|
194
|
+
"空山新雨后"
|
|
195
|
+
]),
|
|
196
|
+
春: Object.freeze([
|
|
197
|
+
"春眠不觉晓",
|
|
198
|
+
"春来江水绿如蓝",
|
|
199
|
+
"春城无处不飞花",
|
|
200
|
+
"春蚕到死丝方尽",
|
|
201
|
+
"野火烧不尽,春风吹又生",
|
|
202
|
+
"春色满园关不住",
|
|
203
|
+
"等闲识得东风面,万紫千红总是春",
|
|
204
|
+
"春潮带雨晚来急",
|
|
205
|
+
"谁言寸草心,报得三春晖",
|
|
206
|
+
"国破山河在,城春草木深",
|
|
207
|
+
"春江潮水连海平"
|
|
208
|
+
]),
|
|
209
|
+
秋: Object.freeze([
|
|
210
|
+
"空山新雨后,天气晚来秋",
|
|
211
|
+
"湖光秋月两相和",
|
|
212
|
+
"自古逢秋悲寂寥",
|
|
213
|
+
"春种一粒粟,秋收万颗子",
|
|
214
|
+
"银烛秋光冷画屏",
|
|
215
|
+
"解落三秋叶,能开二月花",
|
|
216
|
+
"万里悲秋常作客",
|
|
217
|
+
"塞下秋来风景异",
|
|
218
|
+
"洛阳城里见秋风",
|
|
219
|
+
"秋风萧瑟天气凉"
|
|
220
|
+
]),
|
|
221
|
+
人: Object.freeze([
|
|
222
|
+
"人生得意须尽欢",
|
|
223
|
+
"人生自古谁无死",
|
|
224
|
+
"人闲桂花落",
|
|
225
|
+
"举杯邀明月,对影成三人",
|
|
226
|
+
"路上行人欲断魂",
|
|
227
|
+
"独在异乡为异客,每逢佳节倍思亲",
|
|
228
|
+
"人面不知何处去",
|
|
229
|
+
"但愿人长久",
|
|
230
|
+
"前不见古人,后不见来者",
|
|
231
|
+
"故人西辞黄鹤楼",
|
|
232
|
+
"莫愁前路无知己,天下谁人不识君",
|
|
233
|
+
"劝君更尽一杯酒,西出阳关无故人",
|
|
234
|
+
"遥知兄弟登高处,遍插茱萸少一人"
|
|
235
|
+
]),
|
|
236
|
+
酒: Object.freeze([
|
|
237
|
+
"花间一壶酒",
|
|
238
|
+
"葡萄美酒夜光杯",
|
|
239
|
+
"借问酒家何处有",
|
|
240
|
+
"劝君更尽一杯酒",
|
|
241
|
+
"对酒当歌",
|
|
242
|
+
"金樽清酒斗十千",
|
|
243
|
+
"酒入愁肠,化作相思泪",
|
|
244
|
+
"今朝有酒今朝醉",
|
|
245
|
+
"白日放歌须纵酒",
|
|
246
|
+
"人生得意须尽欢,莫使金樽空对月",
|
|
247
|
+
"明月几时有,把酒问青天"
|
|
248
|
+
]),
|
|
249
|
+
夜: Object.freeze([
|
|
250
|
+
"夜来风雨声",
|
|
251
|
+
"忽如一夜春风来",
|
|
252
|
+
"巴山夜雨涨秋池",
|
|
253
|
+
"夜发清溪向三峡",
|
|
254
|
+
"姑苏城外寒山寺,夜半钟声到客船",
|
|
255
|
+
"东风夜放花千树",
|
|
256
|
+
"夜阑卧听风吹雨",
|
|
257
|
+
"可怜九月初三夜",
|
|
258
|
+
"随风直到夜郎西",
|
|
259
|
+
"烟笼寒水月笼沙,夜泊秦淮近酒家",
|
|
260
|
+
"夜深千帐灯",
|
|
261
|
+
"月黑雁飞高,单于夜遁逃"
|
|
262
|
+
]),
|
|
263
|
+
江: Object.freeze([
|
|
264
|
+
"孤帆远影碧空尽,唯见长江天际流",
|
|
265
|
+
"春来江水绿如蓝",
|
|
266
|
+
"春风又绿江南岸",
|
|
267
|
+
"一江春水向东流",
|
|
268
|
+
"天门中断楚江开",
|
|
269
|
+
"寒雨连江夜入吴",
|
|
270
|
+
"竹外桃花三两枝,春江水暖鸭先知",
|
|
271
|
+
"月落乌啼霜满天,江枫渔火对愁眠",
|
|
272
|
+
"江畔何人初见月,江月何年初照人",
|
|
273
|
+
"朝辞白帝彩云间,千里江陵一日还",
|
|
274
|
+
"不尽长江滚滚流",
|
|
275
|
+
"杨柳青青江水平"
|
|
276
|
+
]),
|
|
277
|
+
日: Object.freeze([
|
|
278
|
+
"白日依山尽",
|
|
279
|
+
"日照香炉生紫烟",
|
|
280
|
+
"千里江陵一日还",
|
|
281
|
+
"映日荷花别样红",
|
|
282
|
+
"锄禾日当午",
|
|
283
|
+
"日出江花红胜火",
|
|
284
|
+
"海日生残夜",
|
|
285
|
+
"大漠孤烟直,长河落日圆",
|
|
286
|
+
"日暮乡关何处是",
|
|
287
|
+
"浮云游子意,落日故人情",
|
|
288
|
+
"白日放歌须纵酒",
|
|
289
|
+
"闲云潭影日悠悠"
|
|
290
|
+
]),
|
|
291
|
+
星: Object.freeze([
|
|
292
|
+
"星垂平野阔",
|
|
293
|
+
"迢迢牵牛星",
|
|
294
|
+
"危楼高百尺,手可摘星辰",
|
|
295
|
+
"微微风簇浪,散作满河星",
|
|
296
|
+
"七八个星天外",
|
|
297
|
+
"天阶夜色凉如水,卧看牵牛织女星",
|
|
298
|
+
"昨夜星辰昨夜风",
|
|
299
|
+
"星汉灿烂,若出其里"
|
|
300
|
+
]),
|
|
301
|
+
天: Object.freeze([
|
|
302
|
+
"天街小雨润如酥",
|
|
303
|
+
"黄河之水天上来",
|
|
304
|
+
"飞流直下三千尺,疑是银河落九天",
|
|
305
|
+
"天生我材必有用",
|
|
306
|
+
"天门中断楚江开",
|
|
307
|
+
"江天一色无纤尘",
|
|
308
|
+
"天阶夜色凉如水",
|
|
309
|
+
"天苍苍,野茫茫",
|
|
310
|
+
"接天莲叶无穷碧",
|
|
311
|
+
"蜀道之难,难于上青天",
|
|
312
|
+
"天若有情天亦老"
|
|
313
|
+
]),
|
|
314
|
+
柳: Object.freeze([
|
|
315
|
+
"羌笛何须怨杨柳",
|
|
316
|
+
"客舍青青柳色新",
|
|
317
|
+
"两个黄鹂鸣翠柳",
|
|
318
|
+
"柳暗花明又一村",
|
|
319
|
+
"月上柳梢头",
|
|
320
|
+
"春城无处不飞花,寒食东风御柳斜",
|
|
321
|
+
"此夜曲中闻折柳",
|
|
322
|
+
"杨柳岸,晓风残月",
|
|
323
|
+
"枝上柳绵吹又少",
|
|
324
|
+
"最是一年春好处,绝胜烟柳满皇都",
|
|
325
|
+
"芙蓉如面柳如眉",
|
|
326
|
+
"杨柳青青江水平"
|
|
327
|
+
]),
|
|
328
|
+
雁: Object.freeze([
|
|
329
|
+
"雁字回时,月满西楼",
|
|
330
|
+
"塞下秋来风景异,衡阳雁去无留意",
|
|
331
|
+
"北风吹雁雪纷纷",
|
|
332
|
+
"征蓬出汉塞,归雁入胡天",
|
|
333
|
+
"月黑雁飞高",
|
|
334
|
+
"雁过也,正伤心,却是旧时相识",
|
|
335
|
+
"秋风起兮白云飞,草木黄落兮雁南归",
|
|
336
|
+
"乡书何处达,归雁洛阳边",
|
|
337
|
+
"何处秋风至,萧萧送雁群"
|
|
338
|
+
]),
|
|
339
|
+
烟: Object.freeze([
|
|
340
|
+
"日照香炉生紫烟",
|
|
341
|
+
"大漠孤烟直",
|
|
342
|
+
"烟笼寒水月笼沙",
|
|
343
|
+
"草长莺飞二月天,拂堤杨柳醉春烟",
|
|
344
|
+
"一蓑烟雨任平生",
|
|
345
|
+
"南朝四百八十寺,多少楼台烟雨中",
|
|
346
|
+
"故人西辞黄鹤楼,烟花三月下扬州",
|
|
347
|
+
"日暮乡关何处是,烟波江上使人愁",
|
|
348
|
+
"绿杨烟外晓寒轻",
|
|
349
|
+
"最是一年春好处,绝胜烟柳满皇都"
|
|
350
|
+
]),
|
|
351
|
+
楼: Object.freeze([
|
|
352
|
+
"故人西辞黄鹤楼",
|
|
353
|
+
"山外青山楼外楼",
|
|
354
|
+
"小楼昨夜又东风",
|
|
355
|
+
"危楼高百尺",
|
|
356
|
+
"欲穷千里目,更上一层楼",
|
|
357
|
+
"无言独上西楼",
|
|
358
|
+
"近水楼台先得月",
|
|
359
|
+
"昨夜西风凋碧树,独上高楼",
|
|
360
|
+
"楼船夜雪瓜洲渡",
|
|
361
|
+
"雁字回时,月满西楼",
|
|
362
|
+
"多少楼台烟雨中",
|
|
363
|
+
"明月楼高休独倚"
|
|
364
|
+
]),
|
|
365
|
+
梦: Object.freeze([
|
|
366
|
+
"夜阑卧听风吹雨,铁马冰河入梦来",
|
|
367
|
+
"梦里不知身是客",
|
|
368
|
+
"人生如梦,一尊还酹江月",
|
|
369
|
+
"庄生晓梦迷蝴蝶",
|
|
370
|
+
"故人入我梦,明我长相忆",
|
|
371
|
+
"夜来幽梦忽还乡",
|
|
372
|
+
"梦回吹角连营",
|
|
373
|
+
"因思杜陵梦,凫雁满回塘"
|
|
374
|
+
])
|
|
375
|
+
});
|
|
376
|
+
/** 开局/换字时随机抽取的令字池(即诗句库的键)。 */
|
|
377
|
+
const LINGZI_POOL = Object.freeze(Object.keys(POEM_BANK));
|
|
378
|
+
/** 常见(好接)令字:简易/中等难度可选池。 */
|
|
379
|
+
const COMMON_LINGZI = /* @__PURE__ */ new Set([
|
|
380
|
+
"花",
|
|
381
|
+
"月",
|
|
382
|
+
"风",
|
|
383
|
+
"雪",
|
|
384
|
+
"山",
|
|
385
|
+
"水",
|
|
386
|
+
"云",
|
|
387
|
+
"雨",
|
|
388
|
+
"春",
|
|
389
|
+
"秋",
|
|
390
|
+
"人",
|
|
391
|
+
"酒",
|
|
392
|
+
"夜",
|
|
393
|
+
"江",
|
|
394
|
+
"日",
|
|
395
|
+
"星",
|
|
396
|
+
"天"
|
|
397
|
+
]);
|
|
398
|
+
Object.freeze(LINGZI_POOL.filter((z) => COMMON_LINGZI.has(z)));
|
|
399
|
+
Object.freeze(LINGZI_POOL.filter((z) => !COMMON_LINGZI.has(z)));
|
|
400
|
+
//#endregion
|
|
401
|
+
//#region src/index.ts
|
|
402
|
+
/**
|
|
403
|
+
* ============================================================
|
|
404
|
+
* dsh-plugin-feihualing —— 飞花令游戏插件(DeepSeek Harness / Cordis)
|
|
405
|
+
* ============================================================
|
|
406
|
+
*
|
|
407
|
+
* 功能总览
|
|
408
|
+
* 1. 游戏模式:simple(简易,诗句任意位置包含令字即可)、
|
|
409
|
+
* ancient(古法严格,令字必须出现在本轮指定位置,按第 1 字到第 7 字循环)。
|
|
410
|
+
* 2. 关键词指令(真实用户消息中识别):开始飞花令、结束飞花令、换字、提示、
|
|
411
|
+
* 认输、暂停、继续飞花令。
|
|
412
|
+
* 3. 每个会话独立的内存游戏状态:令字、得分、usedPoems(已使用诗句集合)、
|
|
413
|
+
* 剩余提示次数等;会话之间互不共享,插件卸载时全部清空。
|
|
414
|
+
* 4. 自动暂停:监听工具调用事件,检测到 shell / 文件写入类工具(bash、pwsh、
|
|
415
|
+
* write、edit 等)调用时自动暂停游戏并把现场快照写入白名单目录。
|
|
416
|
+
* 5. 注册 3 个模型工具:feihualing_start、feihualing_status、feihualing_stop。
|
|
417
|
+
* 插件自身不向主聊天流输出任何内容——游戏播报由模型根据工具返回值完成,
|
|
418
|
+
* 从而不污染主聊天输出流(框架规则 7)。
|
|
419
|
+
* 6. 配置项:enable(总开关)、maxHintCount(最大提示次数)、autoPause(自动暂停开关)。
|
|
420
|
+
*
|
|
421
|
+
* Harness 事件映射(需求事件名 → 本 Harness 实际事件)
|
|
422
|
+
* user:message → 会话事件 user/message(仅处理 source.kind === 'user' 的真实输入)
|
|
423
|
+
* turn:before → 会话事件 turn/start
|
|
424
|
+
* turn:after → 会话事件 turn/end
|
|
425
|
+
* tool:before → 会话事件 tool/call(工具调用落盘,执行前可见)
|
|
426
|
+
* plugin:unload → Cordis fiber dispose:用 ctx.effect 清理函数实现(同时保留
|
|
427
|
+
* 字面量 'plugin:unload' 监听作为兼容兜底)
|
|
428
|
+
*
|
|
429
|
+
* 文件读写安全
|
|
430
|
+
* 快照根目录固定为 $DSH_HOME/storages/dsh-plugin-feihualing(白名单)。
|
|
431
|
+
* 所有读写先经过 assertSnapshotPath() 校验:会话 id 仅允许
|
|
432
|
+
* [A-Za-z0-9._-]{1,64}、路径拼接后必须仍位于白名单根目录内(防目录穿越)、
|
|
433
|
+
* 快照文件大小上限 64KB、内容逐字段校验。
|
|
434
|
+
*
|
|
435
|
+
* 约束(Host 半边)
|
|
436
|
+
* Host 不使用定时器、无后台任务、不引入任何第三方 npm 运行时依赖
|
|
437
|
+
* (唯一运行时 import 是 Harness 原生包 @deepseek-ai/dsh-tools);
|
|
438
|
+
* 诗句库与判定规则抽到 src/shared(Host 与浏览器 Client 共用纯模块,
|
|
439
|
+
* 保证“对话判定”与“面板即时判定”规则完全一致)。
|
|
440
|
+
* 浏览器 UI 半边见 src/client:内置对手即时对战,不走模型回合。
|
|
441
|
+
*/
|
|
42
442
|
/** 插件在 Loader 中的显示名。 */
|
|
43
|
-
|
|
443
|
+
const name = "feihualing";
|
|
44
444
|
/** 依赖注入:等待 Harness 的 tools 服务就绪后再加载本插件。 */
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// 模块级常量(纯数据,无任何状态与副作用)
|
|
48
|
-
// ============================================================
|
|
49
|
-
const PLUGIN_ID = 'dsh-plugin-feihualing';
|
|
445
|
+
const inject = ["tools"];
|
|
446
|
+
const PLUGIN_ID = "dsh-plugin-feihualing";
|
|
50
447
|
/** 快照格式版本。 */
|
|
51
448
|
const SNAPSHOT_VERSION = 1;
|
|
52
449
|
/** 快照文件大小上限(字节),防止恶意/损坏文件拖垮读取。 */
|
|
53
|
-
const MAX_SNAPSHOT_BYTES =
|
|
54
|
-
/** 诗句尝试的最小/最大长度(规范化后字符数)。 */
|
|
55
|
-
const MIN_LINE_CHARS = 4;
|
|
56
|
-
const MAX_LINE_CHARS = 48;
|
|
57
|
-
/** 古法令字位置循环上限(第 1 字 → 第 7 字 → 第 1 字)。 */
|
|
58
|
-
const MAX_ANCIENT_POSITION = 7;
|
|
59
|
-
/**
|
|
60
|
-
* 提示诗句库:令字 → 常见古诗词名句(仅用于“提示”功能,不参与判定;
|
|
61
|
-
* 判定只查“含令字 + 未重复 + 古法位置”,因此插件不依赖任何古诗数据库)。
|
|
62
|
-
* 全部为公有领域经典诗句。
|
|
63
|
-
*/
|
|
64
|
-
const POEM_BANK = Object.freeze({
|
|
65
|
-
花: ['花间一壶酒', '感时花溅泪', '人面桃花相映红', '霜叶红于二月花', '花落知多少', '春城无处不飞花'],
|
|
66
|
-
月: ['床前明月光', '举头望明月', '海上生明月', '月落乌啼霜满天', '月是故乡明', '明月几时有'],
|
|
67
|
-
风: ['夜来风雨声', '春风又绿江南岸', '风吹草低见牛羊', '二月春风似剪刀', '春风不度玉门关', '长风破浪会有时'],
|
|
68
|
-
雪: ['窗含西岭千秋雪', '独钓寒江雪', '柴门闻犬吠,风雪夜归人', '北风吹雁雪纷纷', '雪上空留马行处', '北风卷地白草折,胡天八月即飞雪'],
|
|
69
|
-
山: ['白日依山尽', '山重水复疑无路', '轻舟已过万重山', '空山不见人', '千山鸟飞绝', '会当凌绝顶,一览众山小'],
|
|
70
|
-
水: ['抽刀断水水更流', '桃花潭水深千尺', '一江春水向东流', '山重水复疑无路', '行到水穷处'],
|
|
71
|
-
云: ['黄河远上白云间', '只在此山中,云深不知处', '白云生处有人家', '行到水穷处,坐看云起时', '众鸟高飞尽,孤云独去闲'],
|
|
72
|
-
雨: ['清明时节雨纷纷', '好雨知时节', '天街小雨润如酥', '巴山夜雨涨秋池', '山色空蒙雨亦奇'],
|
|
73
|
-
春: ['春眠不觉晓', '春来江水绿如蓝', '春城无处不飞花', '春蚕到死丝方尽', '野火烧不尽,春风吹又生', '春风又绿江南岸'],
|
|
74
|
-
秋: ['空山新雨后,天气晚来秋', '湖光秋月两相和', '自古逢秋悲寂寥', '春种一粒粟,秋收万颗子'],
|
|
75
|
-
人: ['人生得意须尽欢', '人生自古谁无死', '人闲桂花落', '举杯邀明月,对影成三人', '路上行人欲断魂'],
|
|
76
|
-
酒: ['花间一壶酒', '葡萄美酒夜光杯', '借问酒家何处有', '劝君更尽一杯酒', '对酒当歌'],
|
|
77
|
-
夜: ['夜来风雨声', '忽如一夜春风来', '巴山夜雨涨秋池', '葡萄美酒夜光杯'],
|
|
78
|
-
江: ['孤帆远影碧空尽,唯见长江天际流', '春来江水绿如蓝', '春风又绿江南岸', '一江春水向东流', '天门中断楚江开'],
|
|
79
|
-
日: ['白日依山尽', '日照香炉生紫烟', '千里江陵一日还', '映日荷花别样红', '锄禾日当午'],
|
|
80
|
-
星: ['星垂平野阔', '迢迢牵牛星', '危楼高百尺,手可摘星辰', '微微风簇浪,散作满河星'],
|
|
81
|
-
});
|
|
82
|
-
/** 开局/换字时随机抽取的令字池(即提示诗句库的键)。 */
|
|
83
|
-
const LINGZI_POOL = Object.keys(POEM_BANK);
|
|
450
|
+
const MAX_SNAPSHOT_BYTES = 65536;
|
|
84
451
|
const KEYWORDS = [
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
452
|
+
{
|
|
453
|
+
action: "stop",
|
|
454
|
+
test: (t) => t.includes("结束飞花令") || t.includes("结束游戏") || t.includes("停止飞花令")
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
action: "start",
|
|
458
|
+
test: (t) => t.includes("开始飞花令") || t.includes("开始游戏")
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
action: "resume",
|
|
462
|
+
test: (t) => t.includes("继续飞花令") || t.includes("继续游戏")
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
action: "pause",
|
|
466
|
+
test: (t) => t.includes("暂停")
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
action: "surrender",
|
|
470
|
+
test: (t) => t.includes("认输")
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
action: "swap",
|
|
474
|
+
test: (t) => t.includes("换字")
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
action: "hint",
|
|
478
|
+
test: (t) => t.includes("提示")
|
|
479
|
+
}
|
|
92
480
|
];
|
|
93
481
|
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const DANGEROUS_TOOL_NAMES = new Set([
|
|
99
|
-
|
|
100
|
-
|
|
482
|
+
* 危险工具名集合(自动暂停触发条件):
|
|
483
|
+
* shell 类(bash/pwsh/shell 等)与文件写入类(write/edit/file_write)。
|
|
484
|
+
* 工具名先剥掉 "tool:" 前缀并转小写再匹配;本插件自己的 feihualing_* 工具除外。
|
|
485
|
+
*/
|
|
486
|
+
const DANGEROUS_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
487
|
+
"bash",
|
|
488
|
+
"shell",
|
|
489
|
+
"sh",
|
|
490
|
+
"zsh",
|
|
491
|
+
"cmd",
|
|
492
|
+
"pwsh",
|
|
493
|
+
"powershell",
|
|
494
|
+
"subprocess",
|
|
495
|
+
"terminal",
|
|
496
|
+
"write",
|
|
497
|
+
"edit",
|
|
498
|
+
"file_write"
|
|
101
499
|
]);
|
|
102
|
-
// ============================================================
|
|
103
|
-
// 模块级纯函数(无状态、无副作用,仅做文本/路径处理)
|
|
104
|
-
// ============================================================
|
|
105
|
-
/**
|
|
106
|
-
* 规范化一行文本:去除空白与常见中英文标点,仅保留汉字/字母/数字。
|
|
107
|
-
* 用于关键词匹配与诗句去重比较。
|
|
108
|
-
*/
|
|
109
|
-
function normalizeLine(raw) {
|
|
110
|
-
return raw.replace(/[\s,。!?、;:""''()《》〈〉【】\[\]·,.!?;:'"()\-—…]+/gu, '');
|
|
111
|
-
}
|
|
112
500
|
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
501
|
+
* 从 user/message 事件的 content 中提取纯文本。
|
|
502
|
+
* 兼容字符串、文本块({ type: 'text'|'input_text', text })等形态。
|
|
503
|
+
*/
|
|
116
504
|
function extractText(content) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
else if (part !== null && typeof part === 'object') {
|
|
127
|
-
const block = part;
|
|
128
|
-
if (typeof block.text === 'string')
|
|
129
|
-
out += block.text;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return out;
|
|
505
|
+
if (typeof content === "string") return content;
|
|
506
|
+
if (!Array.isArray(content)) return "";
|
|
507
|
+
let out = "";
|
|
508
|
+
for (const part of content) if (typeof part === "string") out += part;
|
|
509
|
+
else if (part !== null && typeof part === "object") {
|
|
510
|
+
const block = part;
|
|
511
|
+
if (typeof block.text === "string") out += block.text;
|
|
512
|
+
}
|
|
513
|
+
return out;
|
|
133
514
|
}
|
|
134
515
|
/** 归一化工具名:去掉 "tool:" 前缀并转小写。 */
|
|
135
516
|
function normalizeToolName(raw) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return raw.replace(/^tool:/, '').toLowerCase();
|
|
517
|
+
if (typeof raw !== "string") return "";
|
|
518
|
+
return raw.replace(/^tool:/, "").toLowerCase();
|
|
139
519
|
}
|
|
140
520
|
/** 统一错误信息提取(catch 变量在 strict 模式下为 unknown)。 */
|
|
141
521
|
function errorMessage(error) {
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
/** 去掉文本首尾空白(用于展示时压缩用户输入的换行/空格)。 */
|
|
145
|
-
function trimDisplay(raw) {
|
|
146
|
-
return raw.trim().replace(/\s+/gu, ' ').slice(0, MAX_LINE_CHARS);
|
|
522
|
+
return error instanceof Error ? error.message : String(error);
|
|
147
523
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
}
|
|
713
|
-
catch {
|
|
714
|
-
/* 目录不存在等情况一律忽略 */
|
|
715
|
-
}
|
|
716
|
-
log('info', `[${PLUGIN_ID}] 插件已卸载:全部会话状态与快照已清空`);
|
|
717
|
-
};
|
|
718
|
-
// 主卸载路径:Cordis fiber dispose 时执行(即需求中的 plugin:unload 语义)
|
|
719
|
-
ctx.effect(() => cleanup, 'dsh-plugin-feihualing: unload cleanup');
|
|
720
|
-
// 兼容兜底:若宿主额外派发字面量 'plugin:unload' 事件同样执行清理(幂等)
|
|
721
|
-
ctx.on('plugin:unload', cleanup);
|
|
722
|
-
log('info', `[${PLUGIN_ID}] 已加载(enable=${String(config.enable)}, maxHintCount=${config.maxHintCount}, autoPause=${String(config.autoPause)},快照目录:${dataDir})`);
|
|
524
|
+
function apply(ctx, rawConfig) {
|
|
525
|
+
const config = resolveConfig(rawConfig);
|
|
526
|
+
/** 统一日志出口:优先 Harness logger,缺失时退回 console。 */
|
|
527
|
+
const log = (level, ...args) => {
|
|
528
|
+
try {
|
|
529
|
+
(ctx.logger ?? console)[level](...args);
|
|
530
|
+
} catch {}
|
|
531
|
+
};
|
|
532
|
+
if (!config.enable) {
|
|
533
|
+
log("info", `[${PLUGIN_ID}] enable=false,插件未启用`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const games = /* @__PURE__ */ new Map();
|
|
537
|
+
/** 各会话最近轮次号(来自 turn/start、turn/end 事件,用于状态展示)。 */
|
|
538
|
+
const lastTurns = /* @__PURE__ */ new Map();
|
|
539
|
+
const dataDir = resolveDataDir();
|
|
540
|
+
/**
|
|
541
|
+
* 快照路径白名单校验(安全关键代码)。
|
|
542
|
+
* 校验三步:
|
|
543
|
+
* 1) 会话 id 必须是严格文件名形式([A-Za-z0-9._-]{1,64}),杜绝 ../ 等穿越字符;
|
|
544
|
+
* 2) path.resolve 后的最终路径必须与“白名单根目录 + 固定文件名”的拼接结果
|
|
545
|
+
* 完全一致(等价于拒绝任何穿越);
|
|
546
|
+
* 3) 最终路径必须位于白名单根目录之内(双保险前缀校验)。
|
|
547
|
+
* @throws 路径非法时抛出异常,调用方负责拦截。
|
|
548
|
+
*/
|
|
549
|
+
function assertSnapshotPath(sessionId) {
|
|
550
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(sessionId)) throw new Error(`非法会话 id(仅允许 [A-Za-z0-9._-]{1,64}): ${JSON.stringify(sessionId)}`);
|
|
551
|
+
const root = path.resolve(dataDir);
|
|
552
|
+
const fileName = `snapshot-${sessionId}.json`;
|
|
553
|
+
const target = path.resolve(root, fileName);
|
|
554
|
+
if (target !== path.join(root, fileName)) throw new Error(`快照路径越界被拒绝: ${target}`);
|
|
555
|
+
if (!target.startsWith(root + path.sep)) throw new Error(`快照路径不在白名单目录内: ${target}`);
|
|
556
|
+
return target;
|
|
557
|
+
}
|
|
558
|
+
/** 保存现场:把会话游戏状态写入白名单目录内的快照文件(同步、原子性足够)。 */
|
|
559
|
+
function saveSnapshot(sessionId, state) {
|
|
560
|
+
try {
|
|
561
|
+
const file = assertSnapshotPath(sessionId);
|
|
562
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
563
|
+
const snapshot = {
|
|
564
|
+
version: SNAPSHOT_VERSION,
|
|
565
|
+
plugin: PLUGIN_ID,
|
|
566
|
+
sessionId,
|
|
567
|
+
savedAt: Date.now(),
|
|
568
|
+
state: {
|
|
569
|
+
...state,
|
|
570
|
+
usedPoems: [...state.usedPoems],
|
|
571
|
+
usedNormalized: [...state.usedNormalized]
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
fs.writeFileSync(file, JSON.stringify(snapshot, null, 2), "utf8");
|
|
575
|
+
log("info", `[${PLUGIN_ID}] 会话 ${sessionId}: 现场已保存到 ${file}`);
|
|
576
|
+
} catch (error) {
|
|
577
|
+
log("warn", `[${PLUGIN_ID}] 会话 ${sessionId}: 保存现场失败: ${errorMessage(error)}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** 删除指定会话的快照文件(对局结束/认输/开局后调用;不存在则忽略)。 */
|
|
581
|
+
function deleteSnapshot(sessionId) {
|
|
582
|
+
try {
|
|
583
|
+
fs.unlinkSync(assertSnapshotPath(sessionId));
|
|
584
|
+
} catch {}
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* 从快照恢复现场(例如宿主重启后同一会话继续)。
|
|
588
|
+
* 文件缺失、超限、JSON 损坏或字段校验失败时返回 undefined。
|
|
589
|
+
*/
|
|
590
|
+
function restoreSnapshot(sessionId) {
|
|
591
|
+
let file;
|
|
592
|
+
try {
|
|
593
|
+
file = assertSnapshotPath(sessionId);
|
|
594
|
+
} catch {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
try {
|
|
598
|
+
if (fs.statSync(file).size > MAX_SNAPSHOT_BYTES) {
|
|
599
|
+
log("warn", `[${PLUGIN_ID}] 会话 ${sessionId}: 快照超过大小上限,已忽略`);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const snapshot = validateSnapshot(JSON.parse(fs.readFileSync(file, "utf8")), sessionId);
|
|
603
|
+
if (snapshot === null) return void 0;
|
|
604
|
+
log("info", `[${PLUGIN_ID}] 会话 ${sessionId}: 已从快照恢复现场(${snapshot.state.phase})`);
|
|
605
|
+
return snapshot.state;
|
|
606
|
+
} catch {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/** 读取会话现场:内存优先,缺失时尝试从快照恢复(恢复后写入内存表)。 */
|
|
611
|
+
function ensureState(sessionId) {
|
|
612
|
+
const existing = games.get(sessionId);
|
|
613
|
+
if (existing) return existing;
|
|
614
|
+
const restored = restoreSnapshot(sessionId);
|
|
615
|
+
if (restored) games.set(sessionId, restored);
|
|
616
|
+
return restored;
|
|
617
|
+
}
|
|
618
|
+
/** 随机抽取令字;与 exclude 相同时重抽(最多重试几次,不用定时器)。 */
|
|
619
|
+
function pickLingzi(exclude) {
|
|
620
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
621
|
+
const lingzi = LINGZI_POOL[Math.floor(Math.random() * LINGZI_POOL.length)];
|
|
622
|
+
if (lingzi !== exclude) return lingzi;
|
|
623
|
+
}
|
|
624
|
+
return LINGZI_POOL[0];
|
|
625
|
+
}
|
|
626
|
+
/** 创建一局新游戏。 */
|
|
627
|
+
function createGame(sessionId, mode) {
|
|
628
|
+
const lingzi = pickLingzi("");
|
|
629
|
+
const modeText = mode === "ancient" ? "古法严格" : "简易";
|
|
630
|
+
return {
|
|
631
|
+
sessionId,
|
|
632
|
+
phase: "running",
|
|
633
|
+
mode,
|
|
634
|
+
lingzi,
|
|
635
|
+
score: 0,
|
|
636
|
+
attempts: 0,
|
|
637
|
+
usedPoems: [],
|
|
638
|
+
usedNormalized: [],
|
|
639
|
+
hintsLeft: config.maxHintCount,
|
|
640
|
+
requiredPosition: 1,
|
|
641
|
+
pausedReason: "",
|
|
642
|
+
lastEvent: `飞花令开始!模式:${modeText},令字「${lingzi}」,剩余提示 ${config.maxHintCount} 次` + (mode === "ancient" ? "(古法要求令字位于第 1 字)" : ""),
|
|
643
|
+
startedAt: Date.now(),
|
|
644
|
+
updatedAt: Date.now()
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/** 暂停游戏并记录原因(不负责写盘,由调用方决定是否保存现场)。 */
|
|
648
|
+
function pauseGame(state, reason) {
|
|
649
|
+
state.phase = "paused";
|
|
650
|
+
state.pausedReason = reason;
|
|
651
|
+
state.lastEvent = `游戏已暂停:${reason}`;
|
|
652
|
+
state.updatedAt = Date.now();
|
|
653
|
+
}
|
|
654
|
+
/** 结束对局:置为 finished、清理暂停原因、删除快照文件。 */
|
|
655
|
+
function finishGame(sessionId, state, message) {
|
|
656
|
+
state.phase = "finished";
|
|
657
|
+
state.pausedReason = "";
|
|
658
|
+
state.lastEvent = message;
|
|
659
|
+
state.updatedAt = Date.now();
|
|
660
|
+
deleteSnapshot(sessionId);
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* 诗句尝试判定(Host 入口)。
|
|
664
|
+
* 规则实现在 shared/rules.ts 的 judgePoemAttempt(Host 与浏览器 Client 共用,
|
|
665
|
+
* 保证“对话判定”与“面板即时判定”规则一致);这里把判定结果落回状态机
|
|
666
|
+
* (得分、去重登记、古法位置推进)并生成播报文案。
|
|
667
|
+
*/
|
|
668
|
+
function attemptPoem(state, rawText) {
|
|
669
|
+
const result = judgePoemAttempt({
|
|
670
|
+
rawText,
|
|
671
|
+
lingzi: state.lingzi,
|
|
672
|
+
mode: state.mode,
|
|
673
|
+
requiredPosition: state.requiredPosition,
|
|
674
|
+
usedNormalized: state.usedNormalized
|
|
675
|
+
});
|
|
676
|
+
if (result.kind === "notPoem") return;
|
|
677
|
+
state.attempts += 1;
|
|
678
|
+
const t = result.display;
|
|
679
|
+
switch (result.kind) {
|
|
680
|
+
case "length":
|
|
681
|
+
state.lastEvent = `「${t}」长度不符(需 4-48 字),本次无效`;
|
|
682
|
+
break;
|
|
683
|
+
case "duplicate":
|
|
684
|
+
state.lastEvent = `「${t}」已使用过,本次无效`;
|
|
685
|
+
break;
|
|
686
|
+
case "badPosition": {
|
|
687
|
+
const pos = result.requiredPosition ?? state.requiredPosition;
|
|
688
|
+
state.lastEvent = result.badPosReason === "tooShort" ? `「${t}」不足 ${pos} 字:古法要求令字「${state.lingzi}」位于第 ${pos} 字` : `位置不符:「${t}」第 ${pos} 字应为令字「${state.lingzi}」`;
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
case "valid":
|
|
692
|
+
state.score += 1;
|
|
693
|
+
state.usedPoems.push(t);
|
|
694
|
+
state.usedNormalized.push(result.normalized);
|
|
695
|
+
if (state.mode === "ancient") state.requiredPosition = state.requiredPosition % 7 + 1;
|
|
696
|
+
state.lastEvent = `判定有效!「${t}」得分 +1(当前 ${state.score} 分)`;
|
|
697
|
+
}
|
|
698
|
+
state.updatedAt = Date.now();
|
|
699
|
+
}
|
|
700
|
+
/** “提示”指令:消耗一次提示次数,从内置诗句库挑一句未使用的示例。 */
|
|
701
|
+
function consumeHint(state) {
|
|
702
|
+
if (state.hintsLeft <= 0) {
|
|
703
|
+
state.lastEvent = "提示次数已用完";
|
|
704
|
+
state.updatedAt = Date.now();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
state.hintsLeft -= 1;
|
|
708
|
+
const candidate = (POEM_BANK[state.lingzi] ?? []).find((entry) => !state.usedNormalized.includes(normalizeLine(entry)));
|
|
709
|
+
const positionNote = state.mode === "ancient" ? `(古法:令字「${state.lingzi}」需位于第 ${state.requiredPosition} 字)` : "";
|
|
710
|
+
state.lastEvent = candidate ? `提示${positionNote}:例如「${candidate}」(仅作参考,请自行创作)` : `提示${positionNote}:请说出一句包含「${state.lingzi}」的诗句`;
|
|
711
|
+
state.updatedAt = Date.now();
|
|
712
|
+
}
|
|
713
|
+
/** 关键词指令处理(无对局时除 start 外一律忽略)。 */
|
|
714
|
+
function applyCommand(sessionId, action) {
|
|
715
|
+
const existing = ensureState(sessionId);
|
|
716
|
+
switch (action) {
|
|
717
|
+
case "start": {
|
|
718
|
+
const next = createGame(sessionId, "simple");
|
|
719
|
+
if (existing) next.lastEvent = `已重新开局(先前的对局已被替换):${next.lastEvent}`;
|
|
720
|
+
games.set(sessionId, next);
|
|
721
|
+
deleteSnapshot(sessionId);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
case "stop":
|
|
725
|
+
if (existing) finishGame(sessionId, existing, `游戏结束:最终得分 ${existing.score},共使用诗句 ${existing.usedPoems.length} 句`);
|
|
726
|
+
return;
|
|
727
|
+
case "surrender":
|
|
728
|
+
if (existing) finishGame(sessionId, existing, `玩家认输:最终得分 ${existing.score},共使用诗句 ${existing.usedPoems.length} 句`);
|
|
729
|
+
return;
|
|
730
|
+
case "pause":
|
|
731
|
+
if (existing && existing.phase === "running") {
|
|
732
|
+
pauseGame(existing, "玩家手动暂停");
|
|
733
|
+
saveSnapshot(sessionId, existing);
|
|
734
|
+
}
|
|
735
|
+
return;
|
|
736
|
+
case "resume":
|
|
737
|
+
if (existing && existing.phase === "paused") {
|
|
738
|
+
existing.phase = "running";
|
|
739
|
+
existing.pausedReason = "";
|
|
740
|
+
existing.lastEvent = "游戏继续";
|
|
741
|
+
existing.updatedAt = Date.now();
|
|
742
|
+
deleteSnapshot(sessionId);
|
|
743
|
+
}
|
|
744
|
+
return;
|
|
745
|
+
case "swap":
|
|
746
|
+
if (existing && (existing.phase === "running" || existing.phase === "paused")) {
|
|
747
|
+
existing.lingzi = pickLingzi(existing.lingzi);
|
|
748
|
+
existing.requiredPosition = 1;
|
|
749
|
+
existing.lastEvent = `令字已更换为「${existing.lingzi}」`;
|
|
750
|
+
existing.updatedAt = Date.now();
|
|
751
|
+
}
|
|
752
|
+
return;
|
|
753
|
+
case "hint":
|
|
754
|
+
if (existing && (existing.phase === "running" || existing.phase === "paused")) consumeHint(existing);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* session/event:所有会话提交事件的统一入口(untagged 监听器全局可见)。
|
|
760
|
+
* 载荷为 (session, event),据此实现需求中的 user:message / turn:before /
|
|
761
|
+
* turn:after / tool:before 四类监听(映射关系见文件头注释)。
|
|
762
|
+
*/
|
|
763
|
+
ctx.on("session/event", (session, event) => {
|
|
764
|
+
try {
|
|
765
|
+
if (session === null || typeof session !== "object" || typeof session.id !== "string") return;
|
|
766
|
+
const sessionId = session.id;
|
|
767
|
+
switch (event?.type) {
|
|
768
|
+
case "user/message": {
|
|
769
|
+
const data = event.data;
|
|
770
|
+
if (data === null || typeof data !== "object") return;
|
|
771
|
+
const message = data;
|
|
772
|
+
if (message.role !== "user") return;
|
|
773
|
+
if (message.source?.kind !== "user") return;
|
|
774
|
+
const text = extractText(message.content);
|
|
775
|
+
if (text.trim().length === 0) return;
|
|
776
|
+
const normalized = normalizeLine(text);
|
|
777
|
+
const keyword = KEYWORDS.find((entry) => entry.test(normalized));
|
|
778
|
+
if (keyword) {
|
|
779
|
+
applyCommand(sessionId, keyword.action);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const state = ensureState(sessionId);
|
|
783
|
+
if (state && state.phase === "running") attemptPoem(state, text);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
case "tool/call": {
|
|
787
|
+
if (!config.autoPause) return;
|
|
788
|
+
const data = event.data;
|
|
789
|
+
const toolName = normalizeToolName(data?.name);
|
|
790
|
+
if (toolName === "" || toolName.startsWith("feihualing")) return;
|
|
791
|
+
if (!DANGEROUS_TOOL_NAMES.has(toolName)) return;
|
|
792
|
+
const state = ensureState(sessionId);
|
|
793
|
+
if (!state || state.phase !== "running") return;
|
|
794
|
+
pauseGame(state, `检测到 ${toolName} 工具调用,自动暂停`);
|
|
795
|
+
saveSnapshot(sessionId, state);
|
|
796
|
+
log("info", `[${PLUGIN_ID}] 会话 ${sessionId}: 因工具 ${toolName} 自动暂停并保存现场`);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
case "turn/start":
|
|
800
|
+
case "turn/end": {
|
|
801
|
+
const data = event.data;
|
|
802
|
+
if (typeof data?.turn === "number") lastTurns.set(sessionId, data.turn);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
default: return;
|
|
806
|
+
}
|
|
807
|
+
} catch (error) {
|
|
808
|
+
log("warn", `[${PLUGIN_ID}] session/event 处理异常: ${errorMessage(error)}`);
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
/** 从工具执行上下文解析会话 id(无 agent 会话时拒绝执行)。 */
|
|
812
|
+
function requireSessionId(exec) {
|
|
813
|
+
const sessionId = exec.agent?.session?.id;
|
|
814
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) throw new Error("飞花令工具必须在 agent 会话中调用");
|
|
815
|
+
return sessionId;
|
|
816
|
+
}
|
|
817
|
+
/** 构造 status 工具返回(usedPoems 返回副本,防止外部修改内部状态)。 */
|
|
818
|
+
function buildStatus(state, sessionId) {
|
|
819
|
+
if (!state) return {
|
|
820
|
+
phase: "idle",
|
|
821
|
+
score: 0,
|
|
822
|
+
attempts: 0,
|
|
823
|
+
usedPoems: [],
|
|
824
|
+
hintsLeft: 0,
|
|
825
|
+
lastEvent: "当前会话没有进行中的飞花令游戏",
|
|
826
|
+
turn: lastTurns.get(sessionId) ?? 0
|
|
827
|
+
};
|
|
828
|
+
return {
|
|
829
|
+
phase: state.phase,
|
|
830
|
+
mode: state.mode,
|
|
831
|
+
lingzi: state.lingzi,
|
|
832
|
+
score: state.score,
|
|
833
|
+
attempts: state.attempts,
|
|
834
|
+
usedPoems: [...state.usedPoems],
|
|
835
|
+
hintsLeft: state.hintsLeft,
|
|
836
|
+
...state.mode === "ancient" ? { requiredPosition: state.requiredPosition } : {},
|
|
837
|
+
...state.pausedReason === "" ? {} : { pausedReason: state.pausedReason },
|
|
838
|
+
lastEvent: state.lastEvent,
|
|
839
|
+
turn: lastTurns.get(sessionId) ?? 0
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
/** status 的文本渲染(工具结果在 UI 中的一句话摘要)。 */
|
|
843
|
+
function renderStatusText(value) {
|
|
844
|
+
if (value.phase === "idle") return "当前没有进行中的飞花令游戏";
|
|
845
|
+
const base = `模式:${value.mode === "ancient" ? "古法严格" : "简易"}|令字「${value.lingzi ?? "?"}」|得分 ${value.score}|已用诗句 ${value.usedPoems.length} 句|剩余提示 ${value.hintsLeft} 次`;
|
|
846
|
+
const posText = value.mode === "ancient" && value.requiredPosition !== void 0 ? `|令字需位于第 ${value.requiredPosition} 字` : "";
|
|
847
|
+
if (value.phase === "paused") return `游戏已暂停(${value.pausedReason ?? "原因未知"})。${base}${posText}`;
|
|
848
|
+
if (value.phase === "finished") return `游戏已结束。${base}`;
|
|
849
|
+
return `游戏进行中。${base}${posText}。最近判定:${value.lastEvent || "无"}`;
|
|
850
|
+
}
|
|
851
|
+
ctx.tools.register(defineTool({
|
|
852
|
+
name: "feihualing_start",
|
|
853
|
+
description: "开始(或重新开始)一场飞花令游戏。当用户说「开始飞花令」「开始游戏」等指令时应调用本工具;游戏状态由插件按会话维护,调用后请以返回值中的 message 为准,用自然语言向用户宣布开局信息(模式、令字、剩余提示次数)。",
|
|
854
|
+
parameters: { mode: {
|
|
855
|
+
type: "string",
|
|
856
|
+
enum: ["simple", "ancient"],
|
|
857
|
+
description: "游戏模式:simple=简易飞花令(诗句任意位置包含令字即可);ancient=古法严格飞花令(令字必须位于本轮指定位置,位置按第 1 字到第 7 字循环)。缺省为 simple。"
|
|
858
|
+
} },
|
|
859
|
+
output: {
|
|
860
|
+
schema: {
|
|
861
|
+
type: "object",
|
|
862
|
+
additionalProperties: false,
|
|
863
|
+
properties: {
|
|
864
|
+
started: {
|
|
865
|
+
type: "boolean",
|
|
866
|
+
required: true
|
|
867
|
+
},
|
|
868
|
+
phase: {
|
|
869
|
+
type: "string",
|
|
870
|
+
required: true,
|
|
871
|
+
enum: [
|
|
872
|
+
"running",
|
|
873
|
+
"paused",
|
|
874
|
+
"finished"
|
|
875
|
+
]
|
|
876
|
+
},
|
|
877
|
+
mode: {
|
|
878
|
+
type: "string",
|
|
879
|
+
required: true,
|
|
880
|
+
enum: ["simple", "ancient"]
|
|
881
|
+
},
|
|
882
|
+
lingzi: {
|
|
883
|
+
type: "string",
|
|
884
|
+
required: true
|
|
885
|
+
},
|
|
886
|
+
score: {
|
|
887
|
+
type: "integer",
|
|
888
|
+
required: true
|
|
889
|
+
},
|
|
890
|
+
hintsLeft: {
|
|
891
|
+
type: "integer",
|
|
892
|
+
required: true
|
|
893
|
+
},
|
|
894
|
+
message: {
|
|
895
|
+
type: "string",
|
|
896
|
+
required: true
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
},
|
|
900
|
+
render: (_args, value) => [{
|
|
901
|
+
type: "text",
|
|
902
|
+
text: value.message
|
|
903
|
+
}]
|
|
904
|
+
},
|
|
905
|
+
async execute(args, exec) {
|
|
906
|
+
const sessionId = requireSessionId(exec);
|
|
907
|
+
const requestedMode = args.mode === "ancient" ? "ancient" : "simple";
|
|
908
|
+
const existing = ensureState(sessionId);
|
|
909
|
+
if (existing && existing.phase === "running") return {
|
|
910
|
+
started: false,
|
|
911
|
+
phase: "running",
|
|
912
|
+
mode: existing.mode,
|
|
913
|
+
lingzi: existing.lingzi,
|
|
914
|
+
score: existing.score,
|
|
915
|
+
hintsLeft: existing.hintsLeft,
|
|
916
|
+
message: `游戏已在进行中(模式:${existing.mode === "ancient" ? "古法严格" : "简易"},令字「${existing.lingzi}」),无需重复开始`
|
|
917
|
+
};
|
|
918
|
+
const state = createGame(sessionId, requestedMode);
|
|
919
|
+
if (existing) state.lastEvent = `已重新开局(先前的对局已被替换):${state.lastEvent}`;
|
|
920
|
+
games.set(sessionId, state);
|
|
921
|
+
deleteSnapshot(sessionId);
|
|
922
|
+
return {
|
|
923
|
+
started: true,
|
|
924
|
+
phase: "running",
|
|
925
|
+
mode: state.mode,
|
|
926
|
+
lingzi: state.lingzi,
|
|
927
|
+
score: state.score,
|
|
928
|
+
hintsLeft: state.hintsLeft,
|
|
929
|
+
message: state.lastEvent
|
|
930
|
+
};
|
|
931
|
+
},
|
|
932
|
+
presentCall: (args) => ({
|
|
933
|
+
card: "generic",
|
|
934
|
+
title: "开始飞花令",
|
|
935
|
+
kind: "other",
|
|
936
|
+
rawInput: args
|
|
937
|
+
})
|
|
938
|
+
}));
|
|
939
|
+
ctx.tools.register(defineTool({
|
|
940
|
+
name: "feihualing_status",
|
|
941
|
+
description: "查询当前会话的飞花令游戏状态(模式、令字、得分、已使用诗句、剩余提示次数、最近判定结果等)。当用户提交诗句或发出游戏指令(换字/提示/认输/暂停/继续飞花令等)后,插件已在后台完成判定;请先调用本工具获取最新状态与最近判定(lastEvent),再据此以自然语言回复用户。",
|
|
942
|
+
parameters: {},
|
|
943
|
+
output: {
|
|
944
|
+
schema: {
|
|
945
|
+
type: "object",
|
|
946
|
+
additionalProperties: false,
|
|
947
|
+
properties: {
|
|
948
|
+
phase: {
|
|
949
|
+
type: "string",
|
|
950
|
+
required: true,
|
|
951
|
+
enum: [
|
|
952
|
+
"idle",
|
|
953
|
+
"running",
|
|
954
|
+
"paused",
|
|
955
|
+
"finished"
|
|
956
|
+
]
|
|
957
|
+
},
|
|
958
|
+
mode: {
|
|
959
|
+
type: "string",
|
|
960
|
+
enum: ["simple", "ancient"]
|
|
961
|
+
},
|
|
962
|
+
lingzi: { type: "string" },
|
|
963
|
+
score: {
|
|
964
|
+
type: "integer",
|
|
965
|
+
required: true
|
|
966
|
+
},
|
|
967
|
+
attempts: {
|
|
968
|
+
type: "integer",
|
|
969
|
+
required: true
|
|
970
|
+
},
|
|
971
|
+
usedPoems: {
|
|
972
|
+
type: "array",
|
|
973
|
+
required: true,
|
|
974
|
+
items: { type: "string" }
|
|
975
|
+
},
|
|
976
|
+
hintsLeft: {
|
|
977
|
+
type: "integer",
|
|
978
|
+
required: true
|
|
979
|
+
},
|
|
980
|
+
requiredPosition: { type: "integer" },
|
|
981
|
+
pausedReason: { type: "string" },
|
|
982
|
+
lastEvent: {
|
|
983
|
+
type: "string",
|
|
984
|
+
required: true
|
|
985
|
+
},
|
|
986
|
+
turn: {
|
|
987
|
+
type: "integer",
|
|
988
|
+
required: true
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
},
|
|
992
|
+
render: (_args, value) => [{
|
|
993
|
+
type: "text",
|
|
994
|
+
text: renderStatusText(value)
|
|
995
|
+
}]
|
|
996
|
+
},
|
|
997
|
+
async execute(_args, exec) {
|
|
998
|
+
const sessionId = requireSessionId(exec);
|
|
999
|
+
return buildStatus(ensureState(sessionId), sessionId);
|
|
1000
|
+
},
|
|
1001
|
+
presentCall: () => ({
|
|
1002
|
+
card: "generic",
|
|
1003
|
+
title: "查询飞花令状态",
|
|
1004
|
+
kind: "other",
|
|
1005
|
+
rawInput: null
|
|
1006
|
+
})
|
|
1007
|
+
}));
|
|
1008
|
+
ctx.tools.register(defineTool({
|
|
1009
|
+
name: "feihualing_stop",
|
|
1010
|
+
description: "结束当前会话的飞花令游戏(含「结束飞花令」「认输」语义的收尾)。调用后返回最终得分与已使用诗句数量,请以自然语言向用户播报结算结果。",
|
|
1011
|
+
parameters: {},
|
|
1012
|
+
output: {
|
|
1013
|
+
schema: {
|
|
1014
|
+
type: "object",
|
|
1015
|
+
additionalProperties: false,
|
|
1016
|
+
properties: {
|
|
1017
|
+
stopped: {
|
|
1018
|
+
type: "boolean",
|
|
1019
|
+
required: true
|
|
1020
|
+
},
|
|
1021
|
+
finalScore: {
|
|
1022
|
+
type: "integer",
|
|
1023
|
+
required: true
|
|
1024
|
+
},
|
|
1025
|
+
usedCount: {
|
|
1026
|
+
type: "integer",
|
|
1027
|
+
required: true
|
|
1028
|
+
},
|
|
1029
|
+
message: {
|
|
1030
|
+
type: "string",
|
|
1031
|
+
required: true
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
},
|
|
1035
|
+
render: (_args, value) => [{
|
|
1036
|
+
type: "text",
|
|
1037
|
+
text: value.message
|
|
1038
|
+
}]
|
|
1039
|
+
},
|
|
1040
|
+
async execute(_args, exec) {
|
|
1041
|
+
const sessionId = requireSessionId(exec);
|
|
1042
|
+
const state = ensureState(sessionId);
|
|
1043
|
+
if (!state || state.phase === "finished") return {
|
|
1044
|
+
stopped: false,
|
|
1045
|
+
finalScore: 0,
|
|
1046
|
+
usedCount: 0,
|
|
1047
|
+
message: "当前没有进行中的飞花令游戏"
|
|
1048
|
+
};
|
|
1049
|
+
finishGame(sessionId, state, `游戏结束:最终得分 ${state.score},共使用诗句 ${state.usedPoems.length} 句`);
|
|
1050
|
+
return {
|
|
1051
|
+
stopped: true,
|
|
1052
|
+
finalScore: state.score,
|
|
1053
|
+
usedCount: state.usedPoems.length,
|
|
1054
|
+
message: state.lastEvent
|
|
1055
|
+
};
|
|
1056
|
+
},
|
|
1057
|
+
presentCall: () => ({
|
|
1058
|
+
card: "generic",
|
|
1059
|
+
title: "结束飞花令",
|
|
1060
|
+
kind: "other",
|
|
1061
|
+
rawInput: null
|
|
1062
|
+
})
|
|
1063
|
+
}));
|
|
1064
|
+
/** 是否已执行过清理(幂等保护:effect 与 plugin:unload 可能先后触发)。 */
|
|
1065
|
+
let disposed = false;
|
|
1066
|
+
/**
|
|
1067
|
+
* 卸载清理:
|
|
1068
|
+
* 1. 清空全部会话的游戏状态与轮次记录(内存状态无残留);
|
|
1069
|
+
* 2. 删除白名单目录内全部快照文件(磁盘现场无残留);
|
|
1070
|
+
* 3. 事件监听由 Cordis 随 fiber 卸载自动注销(ctx.on 挂载的 effect)。
|
|
1071
|
+
*/
|
|
1072
|
+
const cleanup = () => {
|
|
1073
|
+
if (disposed) return;
|
|
1074
|
+
disposed = true;
|
|
1075
|
+
games.clear();
|
|
1076
|
+
lastTurns.clear();
|
|
1077
|
+
try {
|
|
1078
|
+
for (const fileName of fs.readdirSync(dataDir)) {
|
|
1079
|
+
if (!/^snapshot-[A-Za-z0-9._-]{1,64}\.json$/.test(fileName)) continue;
|
|
1080
|
+
fs.unlinkSync(path.join(dataDir, fileName));
|
|
1081
|
+
}
|
|
1082
|
+
} catch {}
|
|
1083
|
+
log("info", `[${PLUGIN_ID}] 插件已卸载:全部会话状态与快照已清空`);
|
|
1084
|
+
};
|
|
1085
|
+
ctx.effect(() => cleanup, "dsh-plugin-feihualing: unload cleanup");
|
|
1086
|
+
ctx.on("plugin:unload", cleanup);
|
|
1087
|
+
log("info", `[${PLUGIN_ID}] 已加载(enable=${String(config.enable)}, maxHintCount=${config.maxHintCount}, autoPause=${String(config.autoPause)},快照目录:${dataDir})`);
|
|
723
1088
|
}
|
|
724
|
-
// ============================================================
|
|
725
|
-
// 模块级辅助函数(apply 的支撑工具,无状态)
|
|
726
|
-
// ============================================================
|
|
727
1089
|
/** 配置解析与严格校验;非法值抛错拒绝加载(与 cordis.yml 的 config schema 一致)。 */
|
|
728
1090
|
function resolveConfig(raw) {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
}
|
|
742
|
-
return { enable, maxHintCount, autoPause };
|
|
1091
|
+
const candidate = raw ?? {};
|
|
1092
|
+
const enable = candidate.enable ?? true;
|
|
1093
|
+
const maxHintCount = candidate.maxHintCount ?? 3;
|
|
1094
|
+
const autoPause = candidate.autoPause ?? true;
|
|
1095
|
+
if (typeof enable !== "boolean") throw new TypeError("dsh-plugin-feihualing: config.enable 必须是布尔值");
|
|
1096
|
+
if (typeof autoPause !== "boolean") throw new TypeError("dsh-plugin-feihualing: config.autoPause 必须是布尔值");
|
|
1097
|
+
if (typeof maxHintCount !== "number" || !Number.isInteger(maxHintCount) || maxHintCount < 0 || maxHintCount > 10) throw new TypeError("dsh-plugin-feihualing: config.maxHintCount 必须是 0-10 的整数");
|
|
1098
|
+
return {
|
|
1099
|
+
enable,
|
|
1100
|
+
maxHintCount,
|
|
1101
|
+
autoPause
|
|
1102
|
+
};
|
|
743
1103
|
}
|
|
744
1104
|
/**
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
1105
|
+
* 解析快照白名单根目录:$DSH_HOME/storages/dsh-plugin-feihualing。
|
|
1106
|
+
* 与 @deepseek-ai/dsh-home-paths 的约定一致:DSH_HOME 环境变量优先
|
|
1107
|
+
* (支持 ~ 前缀展开),否则使用 ~/.dsh。
|
|
1108
|
+
*/
|
|
749
1109
|
function resolveDataDir() {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
: path.join(os.homedir(), '.dsh');
|
|
754
|
-
return path.join(home, 'storages', PLUGIN_ID);
|
|
1110
|
+
const envHome = process.env.DSH_HOME;
|
|
1111
|
+
const home = envHome !== void 0 && envHome.trim().length > 0 ? path.resolve(envHome.trim().replace(/^~(?=[\\/])/, os.homedir())) : path.join(os.homedir(), ".dsh");
|
|
1112
|
+
return path.join(home, "storages", PLUGIN_ID);
|
|
755
1113
|
}
|
|
756
1114
|
/**
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1115
|
+
* 快照内容逐字段校验(读取自磁盘的数据一律视为不可信)。
|
|
1116
|
+
* 校验通过返回规范化 SnapshotFile,否则返回 null。
|
|
1117
|
+
*/
|
|
760
1118
|
function validateSnapshot(raw, sessionId) {
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
}
|
|
826
|
-
if (usedNormalized.length > 10000)
|
|
827
|
-
return null;
|
|
828
|
-
return {
|
|
829
|
-
version: SNAPSHOT_VERSION,
|
|
830
|
-
plugin: PLUGIN_ID,
|
|
831
|
-
sessionId,
|
|
832
|
-
savedAt: snapshot.savedAt,
|
|
833
|
-
state: {
|
|
834
|
-
sessionId,
|
|
835
|
-
phase,
|
|
836
|
-
mode,
|
|
837
|
-
lingzi: st.lingzi,
|
|
838
|
-
score: st.score,
|
|
839
|
-
attempts: st.attempts,
|
|
840
|
-
usedPoems,
|
|
841
|
-
usedNormalized,
|
|
842
|
-
hintsLeft: st.hintsLeft,
|
|
843
|
-
requiredPosition: st.requiredPosition,
|
|
844
|
-
pausedReason: st.pausedReason,
|
|
845
|
-
lastEvent: st.lastEvent,
|
|
846
|
-
startedAt: st.startedAt,
|
|
847
|
-
updatedAt: st.updatedAt,
|
|
848
|
-
},
|
|
849
|
-
};
|
|
1119
|
+
if (raw === null || typeof raw !== "object") return null;
|
|
1120
|
+
const snapshot = raw;
|
|
1121
|
+
if (snapshot.version !== SNAPSHOT_VERSION) return null;
|
|
1122
|
+
if (snapshot.plugin !== PLUGIN_ID) return null;
|
|
1123
|
+
if (snapshot.sessionId !== sessionId) return null;
|
|
1124
|
+
if (typeof snapshot.savedAt !== "number") return null;
|
|
1125
|
+
const rawState = snapshot.state;
|
|
1126
|
+
if (rawState === null || typeof rawState !== "object") return null;
|
|
1127
|
+
const st = rawState;
|
|
1128
|
+
if (st.sessionId !== sessionId) return null;
|
|
1129
|
+
const rawPhase = st.phase;
|
|
1130
|
+
if (typeof rawPhase !== "string") return null;
|
|
1131
|
+
const phase = rawPhase;
|
|
1132
|
+
if (phase !== "running" && phase !== "paused" && phase !== "finished") return null;
|
|
1133
|
+
const rawMode = st.mode;
|
|
1134
|
+
if (typeof rawMode !== "string") return null;
|
|
1135
|
+
const mode = rawMode;
|
|
1136
|
+
if (mode !== "simple" && mode !== "ancient") return null;
|
|
1137
|
+
if (typeof st.lingzi !== "string" || st.lingzi.length === 0) return null;
|
|
1138
|
+
if (typeof st.score !== "number" || !Number.isFinite(st.score)) return null;
|
|
1139
|
+
if (typeof st.attempts !== "number" || !Number.isFinite(st.attempts)) return null;
|
|
1140
|
+
if (typeof st.hintsLeft !== "number" || !Number.isFinite(st.hintsLeft)) return null;
|
|
1141
|
+
if (typeof st.requiredPosition !== "number" || !Number.isInteger(st.requiredPosition) || st.requiredPosition < 1) return null;
|
|
1142
|
+
if (typeof st.pausedReason !== "string") return null;
|
|
1143
|
+
if (typeof st.lastEvent !== "string") return null;
|
|
1144
|
+
if (typeof st.startedAt !== "number") return null;
|
|
1145
|
+
if (typeof st.updatedAt !== "number") return null;
|
|
1146
|
+
const rawPoems = st.usedPoems;
|
|
1147
|
+
if (!Array.isArray(rawPoems)) return null;
|
|
1148
|
+
const usedPoems = [];
|
|
1149
|
+
for (const item of rawPoems) {
|
|
1150
|
+
if (typeof item !== "string") return null;
|
|
1151
|
+
usedPoems.push(item);
|
|
1152
|
+
}
|
|
1153
|
+
const rawNormalized = st.usedNormalized;
|
|
1154
|
+
if (!Array.isArray(rawNormalized)) return null;
|
|
1155
|
+
const usedNormalized = [];
|
|
1156
|
+
for (const item of rawNormalized) {
|
|
1157
|
+
if (typeof item !== "string") return null;
|
|
1158
|
+
usedNormalized.push(item);
|
|
1159
|
+
}
|
|
1160
|
+
if (usedNormalized.length > 1e4) return null;
|
|
1161
|
+
return {
|
|
1162
|
+
version: SNAPSHOT_VERSION,
|
|
1163
|
+
plugin: PLUGIN_ID,
|
|
1164
|
+
sessionId,
|
|
1165
|
+
savedAt: snapshot.savedAt,
|
|
1166
|
+
state: {
|
|
1167
|
+
sessionId,
|
|
1168
|
+
phase,
|
|
1169
|
+
mode,
|
|
1170
|
+
lingzi: st.lingzi,
|
|
1171
|
+
score: st.score,
|
|
1172
|
+
attempts: st.attempts,
|
|
1173
|
+
usedPoems,
|
|
1174
|
+
usedNormalized,
|
|
1175
|
+
hintsLeft: st.hintsLeft,
|
|
1176
|
+
requiredPosition: st.requiredPosition,
|
|
1177
|
+
pausedReason: st.pausedReason,
|
|
1178
|
+
lastEvent: st.lastEvent,
|
|
1179
|
+
startedAt: st.startedAt,
|
|
1180
|
+
updatedAt: st.updatedAt
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
850
1183
|
}
|
|
851
|
-
//#
|
|
1184
|
+
//#endregion
|
|
1185
|
+
export { apply, inject, name };
|