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/lib/client.js ADDED
@@ -0,0 +1,1464 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-plugin-feihualing",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ //#region \0rolldown/runtime.js
8
+ var __create = Object.create;
9
+ var __defProp = Object.defineProperty;
10
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
11
+ var __getOwnPropNames = Object.getOwnPropertyNames;
12
+ var __getProtoOf = Object.getPrototypeOf;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
15
+ var __copyProps = (to, from, except, desc) => {
16
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
17
+ key = keys[i];
18
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
19
+ get: ((k) => from[k]).bind(null, key),
20
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
21
+ });
22
+ }
23
+ return to;
24
+ };
25
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
26
+ value: mod,
27
+ enumerable: true
28
+ }) : target, mod));
29
+ //#endregion
30
+ let react = require("react");
31
+ react = __toESM(react, 1);
32
+ let react_dom = require("react-dom");
33
+ //#region src/client/styles.ts
34
+ /**
35
+ * 客户端半边样式:一次性注入 <style>,全部 fhl-* 类以 data-plugin 根类名限定作用域。
36
+ * 主题跟随 DSH:深色模式由 body[data-ds-dark-theme] 标识(官方约定),
37
+ * 浅色/深色各写一套语义配色;不依赖任何宿主私有类名。
38
+ * @module dsh-plugin-feihualing/client/styles
39
+ */
40
+ const CSS = `
41
+ [data-plugin="dsh-plugin-feihualing"].fhl-root, .fhl-root {
42
+ --fhl-bg: #ffffff;
43
+ --fhl-bg-elev: #f4f4f5;
44
+ --fhl-fg: #27272a;
45
+ --fhl-fg-muted: #71717a;
46
+ --fhl-accent: #8b5cf6;
47
+ --fhl-accent-soft: rgba(139, 92, 246, 0.12);
48
+ --fhl-border: rgba(0, 0, 0, 0.1);
49
+ --fhl-ok: #16a34a;
50
+ --fhl-ok-soft: rgba(22, 163, 74, 0.12);
51
+ --fhl-bad: #dc2626;
52
+ --fhl-bad-soft: rgba(220, 38, 38, 0.12);
53
+ font-family: var(--dsh-content-font, ui-sans-serif, system-ui, "PingFang SC", "Microsoft YaHei", sans-serif);
54
+ box-sizing: border-box;
55
+ }
56
+ body[data-ds-dark-theme] .fhl-root {
57
+ --fhl-bg: #18181b;
58
+ --fhl-bg-elev: #27272a;
59
+ --fhl-fg: #e4e4e7;
60
+ --fhl-fg-muted: #a1a1aa;
61
+ --fhl-accent: #a78bfa;
62
+ --fhl-accent-soft: rgba(167, 139, 250, 0.16);
63
+ --fhl-border: rgba(255, 255, 255, 0.12);
64
+ --fhl-ok: #4ade80;
65
+ --fhl-ok-soft: rgba(74, 222, 128, 0.16);
66
+ --fhl-bad: #f87171;
67
+ --fhl-bad-soft: rgba(248, 113, 113, 0.16);
68
+ }
69
+ .fhl-root *, .fhl-root *::before, .fhl-root *::after { box-sizing: border-box; }
70
+
71
+ /* 会话头操作行的小按钮 */
72
+ .fhl-toggle {
73
+ display: inline-flex;
74
+ align-items: center;
75
+ justify-content: center;
76
+ min-width: 28px;
77
+ height: 24px;
78
+ padding: 0 6px;
79
+ border: 1px solid transparent;
80
+ border-radius: 6px;
81
+ background: transparent;
82
+ color: var(--fhl-fg-muted);
83
+ font-size: 14px;
84
+ line-height: 1;
85
+ cursor: pointer;
86
+ transition: background 0.15s ease, color 0.15s ease;
87
+ }
88
+ .fhl-toggle:hover { background: var(--fhl-accent-soft); color: var(--fhl-fg); }
89
+ .fhl-toggle[data-active="true"] { background: var(--fhl-accent-soft); color: var(--fhl-accent); }
90
+
91
+ /* 浮层游戏面板(portal 到 body 的独立 surface,自带作用域) */
92
+ .fhl-panel {
93
+ position: fixed;
94
+ top: 64px;
95
+ right: 16px;
96
+ width: 340px;
97
+ max-width: calc(100vw - 24px);
98
+ max-height: calc(100vh - 88px);
99
+ overflow: auto;
100
+ z-index: 9999;
101
+ display: flex;
102
+ flex-direction: column;
103
+ gap: 10px;
104
+ padding: 14px;
105
+ border-radius: 14px;
106
+ background: var(--fhl-bg);
107
+ color: var(--fhl-fg);
108
+ border: 1px solid var(--fhl-border);
109
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
110
+ }
111
+ .fhl-panel-header {
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: space-between;
115
+ gap: 8px;
116
+ }
117
+ .fhl-panel-title { font-weight: 700; font-size: 14px; letter-spacing: 0.02em; }
118
+ .fhl-panel-close {
119
+ border: none;
120
+ background: transparent;
121
+ color: var(--fhl-fg-muted);
122
+ cursor: pointer;
123
+ font-size: 14px;
124
+ line-height: 1;
125
+ padding: 4px;
126
+ border-radius: 6px;
127
+ }
128
+ .fhl-panel-close:hover { background: var(--fhl-bad-soft); color: var(--fhl-bad); }
129
+ .fhl-muted { color: var(--fhl-fg-muted); }
130
+ .fhl-tag { font-size: 11px; color: var(--fhl-fg-muted); border: 1px solid var(--fhl-border); padding: 1px 6px; border-radius: 999px; }
131
+
132
+ /* ---- 对战 UI ---- */
133
+ .fhl-row { display: flex; align-items: center; gap: 8px; }
134
+ .fhl-seg-label { font-size: 12px; color: var(--fhl-fg-muted); flex: none; width: 34px; }
135
+ .fhl-seg { display: flex; gap: 4px; flex: 1; }
136
+ .fhl-seg-item {
137
+ flex: 1;
138
+ padding: 5px 0;
139
+ font-size: 12px;
140
+ border: 1px solid var(--fhl-border);
141
+ border-radius: 8px;
142
+ background: transparent;
143
+ color: var(--fhl-fg-muted);
144
+ cursor: pointer;
145
+ transition: all 0.15s ease;
146
+ }
147
+ .fhl-seg-item:hover { border-color: var(--fhl-accent); color: var(--fhl-fg); }
148
+ .fhl-seg-item.is-active { background: var(--fhl-accent-soft); border-color: var(--fhl-accent); color: var(--fhl-accent); font-weight: 600; }
149
+
150
+ .fhl-stage { display: flex; align-items: center; gap: 8px; }
151
+ .fhl-stage-round { font-size: 12px; color: var(--fhl-fg-muted); }
152
+ .fhl-timer {
153
+ font-variant-numeric: tabular-nums;
154
+ font-size: 13px;
155
+ font-weight: 700;
156
+ color: var(--fhl-fg);
157
+ padding: 2px 8px;
158
+ border-radius: 999px;
159
+ background: var(--fhl-bg-elev);
160
+ }
161
+ .fhl-timer.is-low { color: var(--fhl-bad); background: var(--fhl-bad-soft); animation: fhl-blink 1s ease-in-out infinite; }
162
+ @keyframes fhl-blink { 50% { opacity: 0.45; } }
163
+ .fhl-stage-tag { margin-left: auto; font-size: 11px; color: var(--fhl-fg-muted); }
164
+
165
+ .fhl-lingzi {
166
+ text-align: center;
167
+ font-size: 34px;
168
+ font-weight: 800;
169
+ line-height: 1.1;
170
+ letter-spacing: 0.08em;
171
+ color: var(--fhl-accent);
172
+ }
173
+
174
+ .fhl-card {
175
+ display: flex;
176
+ flex-direction: column;
177
+ gap: 4px;
178
+ padding: 10px 12px;
179
+ border-radius: 12px;
180
+ background: var(--fhl-bg-elev);
181
+ border: 1px solid var(--fhl-border);
182
+ }
183
+ .fhl-card-role { font-size: 11px; color: var(--fhl-fg-muted); }
184
+ .fhl-card-line { font-size: 15px; line-height: 1.5; }
185
+
186
+ .fhl-verdict { font-size: 12px; color: var(--fhl-fg-muted); min-height: 16px; }
187
+
188
+ .fhl-input-row { display: flex; gap: 6px; }
189
+ .fhl-input {
190
+ flex: 1;
191
+ min-width: 0;
192
+ padding: 7px 10px;
193
+ font-size: 13px;
194
+ border-radius: 9px;
195
+ border: 1px solid var(--fhl-border);
196
+ background: var(--fhl-bg);
197
+ color: var(--fhl-fg);
198
+ outline: none;
199
+ transition: border-color 0.15s ease;
200
+ }
201
+ .fhl-input:focus { border-color: var(--fhl-accent); }
202
+ .fhl-input:disabled { opacity: 0.5; }
203
+
204
+ .fhl-actions { display: flex; gap: 6px; }
205
+ .fhl-btn {
206
+ flex: 1;
207
+ padding: 7px 10px;
208
+ font-size: 13px;
209
+ border: 1px solid var(--fhl-border);
210
+ border-radius: 9px;
211
+ background: var(--fhl-bg-elev);
212
+ color: var(--fhl-fg);
213
+ cursor: pointer;
214
+ transition: all 0.15s ease;
215
+ }
216
+ .fhl-btn:hover:not(:disabled) { border-color: var(--fhl-accent); }
217
+ .fhl-btn:disabled { opacity: 0.45; cursor: default; }
218
+ .fhl-btn-primary { background: var(--fhl-accent-soft); border-color: var(--fhl-accent); color: var(--fhl-accent); font-weight: 600; }
219
+ .fhl-btn-danger { color: var(--fhl-bad); }
220
+ .fhl-btn-danger:hover:not(:disabled) { border-color: var(--fhl-bad); background: var(--fhl-bad-soft); }
221
+
222
+ .fhl-stats {
223
+ display: flex;
224
+ gap: 6px;
225
+ justify-content: space-between;
226
+ padding-top: 2px;
227
+ border-top: 1px dashed var(--fhl-border);
228
+ }
229
+ .fhl-stat { font-size: 11px; color: var(--fhl-fg-muted); display: inline-flex; gap: 3px; align-items: baseline; }
230
+ .fhl-stat b { color: var(--fhl-fg); font-size: 13px; }
231
+ .fhl-stat.is-warn b { color: var(--fhl-bad); }
232
+
233
+ .fhl-result { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 0 4px; }
234
+ .fhl-result-icon { font-size: 40px; }
235
+ .fhl-result-title { font-size: 18px; font-weight: 800; }
236
+ .fhl-result.is-won .fhl-result-title { color: var(--fhl-ok); }
237
+ .fhl-result.is-lost .fhl-result-title { color: var(--fhl-bad); }
238
+
239
+ .fhl-record {
240
+ display: flex;
241
+ justify-content: center;
242
+ gap: 10px;
243
+ font-size: 11px;
244
+ color: var(--fhl-fg-muted);
245
+ }
246
+ `;
247
+ /** 注入样式表;返回 disposer(卸载时移除 style 节点)。 */
248
+ function injectStyles() {
249
+ const style = document.createElement("style");
250
+ style.setAttribute("data-plugin", "dsh-plugin-feihualing");
251
+ style.textContent = CSS;
252
+ document.head.appendChild(style);
253
+ return () => {
254
+ if (style.parentNode) style.parentNode.removeChild(style);
255
+ };
256
+ }
257
+ //#endregion
258
+ //#region src/client/constants.ts
259
+ /**
260
+ * 客户端半边共享常量。
261
+ * NAMESPACE 用作插槽注册 id,须与包名(package.json name / cordis.patch.yml)一致。
262
+ * @module dsh-plugin-feihualing/client/constants
263
+ */
264
+ /** 插件命名空间(与 cordis.patch.yml 的 id、Loader 行 name 保持一致)。 */
265
+ const NAMESPACE = "dsh-plugin-feihualing";
266
+ /**
267
+ * 规范化一行文本:去除空白与常见中英文标点,仅保留汉字/字母/数字。
268
+ * 用于关键词匹配与诗句去重比较。
269
+ */
270
+ function normalizeLine(raw) {
271
+ return raw.replace(/[\s,。!?、;:""''()《》〈〉【】\[\]·,.!?;:'"()\-—…]+/gu, "");
272
+ }
273
+ /** 去掉文本首尾空白(用于展示时压缩用户输入的换行/空格)。 */
274
+ function trimDisplay(raw) {
275
+ return raw.trim().replace(/\s+/gu, " ").slice(0, 48);
276
+ }
277
+ /**
278
+ * 诗句判定核心(纯函数,Host/Client 共用)。
279
+ * 判定顺序与对话版完全一致:含令字门槛 → 长度 → 去重 → 古法位置。
280
+ */
281
+ function judgePoemAttempt(input) {
282
+ const normalized = normalizeLine(input.rawText);
283
+ const display = trimDisplay(input.rawText);
284
+ if (!normalized.includes(input.lingzi)) return {
285
+ kind: "notPoem",
286
+ normalized,
287
+ display,
288
+ length: normalized.length
289
+ };
290
+ if (normalized.length < 4 || normalized.length > 48) return {
291
+ kind: "length",
292
+ normalized,
293
+ display,
294
+ length: normalized.length
295
+ };
296
+ if (input.usedNormalized.includes(normalized)) return {
297
+ kind: "duplicate",
298
+ normalized,
299
+ display,
300
+ length: normalized.length
301
+ };
302
+ if (input.mode === "ancient") {
303
+ const pos = input.requiredPosition;
304
+ if (normalized.length < pos) return {
305
+ kind: "badPosition",
306
+ badPosReason: "tooShort",
307
+ requiredPosition: pos,
308
+ normalized,
309
+ display,
310
+ length: normalized.length
311
+ };
312
+ if (normalized[pos - 1] !== input.lingzi) return {
313
+ kind: "badPosition",
314
+ badPosReason: "mismatch",
315
+ requiredPosition: pos,
316
+ normalized,
317
+ display,
318
+ length: normalized.length
319
+ };
320
+ }
321
+ return {
322
+ kind: "valid",
323
+ normalized,
324
+ display,
325
+ length: normalized.length
326
+ };
327
+ }
328
+ //#endregion
329
+ //#region src/shared/poems.ts
330
+ /**
331
+ * 共享诗句库(Host 与 Client 两半共同使用;纯数据,零平台依赖)。
332
+ * 用途:Host —— 提示(hint)与令字抽取;Client —— 内置 AI 对手出句与提示。
333
+ * 判定本身不依赖本库(只查“含令字 + 未重复 + 古法位置”),因此诗句库的
334
+ * 覆盖面只影响“对手出句 / 提示”的花样,不影响规则正确性。
335
+ * 全部为公共领域经典诗句(唐/宋/元/清为主,个别汉魏、民歌与古乐府)。
336
+ * @module dsh-plugin-feihualing/shared/poems
337
+ */
338
+ /** 令字 → 含该令字的经典诗句(规范化前原文,展示与去重用;每句必含令字)。 */
339
+ const POEM_BANK = Object.freeze({
340
+ 花: Object.freeze([
341
+ "花间一壶酒",
342
+ "感时花溅泪",
343
+ "人面桃花相映红",
344
+ "霜叶红于二月花",
345
+ "花落知多少",
346
+ "春城无处不飞花",
347
+ "不是花中偏爱菊",
348
+ "黄四娘家花满蹊",
349
+ "竹外桃花三两枝",
350
+ "映日荷花别样红",
351
+ "花重锦官城",
352
+ "乱花渐欲迷人眼"
353
+ ]),
354
+ 月: Object.freeze([
355
+ "床前明月光",
356
+ "举头望明月",
357
+ "海上生明月",
358
+ "月落乌啼霜满天",
359
+ "月是故乡明",
360
+ "明月几时有",
361
+ "二十四桥明月夜",
362
+ "月上柳梢头",
363
+ "露似真珠月似弓",
364
+ "月黑雁飞高",
365
+ "深林人不知,明月来相照",
366
+ "明月松间照",
367
+ "江清月近人",
368
+ "海上明月共潮生",
369
+ "杨柳岸,晓风残月",
370
+ "月有阴晴圆缺"
371
+ ]),
372
+ 风: Object.freeze([
373
+ "夜来风雨声",
374
+ "春风又绿江南岸",
375
+ "风吹草低见牛羊",
376
+ "二月春风似剪刀",
377
+ "春风不度玉门关",
378
+ "长风破浪会有时",
379
+ "随风潜入夜",
380
+ "野火烧不尽,春风吹又生",
381
+ "大风起兮云飞扬",
382
+ "风萧萧兮易水寒",
383
+ "昨夜西风凋碧树",
384
+ "吹面不寒杨柳风",
385
+ "古道西风瘦马",
386
+ "北风卷地白草折"
387
+ ]),
388
+ 雪: Object.freeze([
389
+ "窗含西岭千秋雪",
390
+ "独钓寒江雪",
391
+ "柴门闻犬吠,风雪夜归人",
392
+ "北风吹雁雪纷纷",
393
+ "雪上空留马行处",
394
+ "北风卷地白草折,胡天八月即飞雪",
395
+ "遥知不是雪,为有暗香来",
396
+ "欲渡黄河冰塞川,将登太行雪满山",
397
+ "燕山雪花大如席",
398
+ "大雪满弓刀",
399
+ "青海长云暗雪山"
400
+ ]),
401
+ 山: Object.freeze([
402
+ "白日依山尽",
403
+ "山重水复疑无路",
404
+ "轻舟已过万重山",
405
+ "空山不见人",
406
+ "千山鸟飞绝",
407
+ "会当凌绝顶,一览众山小",
408
+ "相看两不厌,只有敬亭山",
409
+ "远上寒山石径斜",
410
+ "两岸青山相对出",
411
+ "国破山河在",
412
+ "青山遮不住",
413
+ "山外青山楼外楼",
414
+ "只在此山中,云深不知处"
415
+ ]),
416
+ 水: Object.freeze([
417
+ "抽刀断水水更流",
418
+ "桃花潭水深千尺",
419
+ "一江春水向东流",
420
+ "行到水穷处",
421
+ "春来江水绿如蓝",
422
+ "白毛浮绿水",
423
+ "水光潋滟晴方好",
424
+ "竹外桃花三两枝,春江水暖鸭先知",
425
+ "曾经沧海难为水",
426
+ "山重水复疑无路,柳暗花明又一村",
427
+ "问君能有几多愁,恰似一江春水向东流",
428
+ "流水落花春去也"
429
+ ]),
430
+ 云: Object.freeze([
431
+ "黄河远上白云间",
432
+ "只在此山中,云深不知处",
433
+ "白云生处有人家",
434
+ "行到水穷处,坐看云起时",
435
+ "众鸟高飞尽,孤云独去闲",
436
+ "黑云压城城欲摧",
437
+ "白云千载空悠悠",
438
+ "朝辞白帝彩云间",
439
+ "云想衣裳花想容",
440
+ "荡胸生层云",
441
+ "青海长云暗雪山",
442
+ "浮云游子意"
443
+ ]),
444
+ 雨: Object.freeze([
445
+ "清明时节雨纷纷",
446
+ "好雨知时节",
447
+ "天街小雨润如酥",
448
+ "巴山夜雨涨秋池",
449
+ "山色空蒙雨亦奇",
450
+ "夜阑卧听风吹雨",
451
+ "渭城朝雨浥轻尘",
452
+ "寒雨连江夜入吴",
453
+ "斜风细雨不须归",
454
+ "沾衣欲湿杏花雨",
455
+ "空山新雨后"
456
+ ]),
457
+ 春: Object.freeze([
458
+ "春眠不觉晓",
459
+ "春来江水绿如蓝",
460
+ "春城无处不飞花",
461
+ "春蚕到死丝方尽",
462
+ "野火烧不尽,春风吹又生",
463
+ "春色满园关不住",
464
+ "等闲识得东风面,万紫千红总是春",
465
+ "春潮带雨晚来急",
466
+ "谁言寸草心,报得三春晖",
467
+ "国破山河在,城春草木深",
468
+ "春江潮水连海平"
469
+ ]),
470
+ 秋: Object.freeze([
471
+ "空山新雨后,天气晚来秋",
472
+ "湖光秋月两相和",
473
+ "自古逢秋悲寂寥",
474
+ "春种一粒粟,秋收万颗子",
475
+ "银烛秋光冷画屏",
476
+ "解落三秋叶,能开二月花",
477
+ "万里悲秋常作客",
478
+ "塞下秋来风景异",
479
+ "洛阳城里见秋风",
480
+ "秋风萧瑟天气凉"
481
+ ]),
482
+ 人: Object.freeze([
483
+ "人生得意须尽欢",
484
+ "人生自古谁无死",
485
+ "人闲桂花落",
486
+ "举杯邀明月,对影成三人",
487
+ "路上行人欲断魂",
488
+ "独在异乡为异客,每逢佳节倍思亲",
489
+ "人面不知何处去",
490
+ "但愿人长久",
491
+ "前不见古人,后不见来者",
492
+ "故人西辞黄鹤楼",
493
+ "莫愁前路无知己,天下谁人不识君",
494
+ "劝君更尽一杯酒,西出阳关无故人",
495
+ "遥知兄弟登高处,遍插茱萸少一人"
496
+ ]),
497
+ 酒: Object.freeze([
498
+ "花间一壶酒",
499
+ "葡萄美酒夜光杯",
500
+ "借问酒家何处有",
501
+ "劝君更尽一杯酒",
502
+ "对酒当歌",
503
+ "金樽清酒斗十千",
504
+ "酒入愁肠,化作相思泪",
505
+ "今朝有酒今朝醉",
506
+ "白日放歌须纵酒",
507
+ "人生得意须尽欢,莫使金樽空对月",
508
+ "明月几时有,把酒问青天"
509
+ ]),
510
+ 夜: Object.freeze([
511
+ "夜来风雨声",
512
+ "忽如一夜春风来",
513
+ "巴山夜雨涨秋池",
514
+ "夜发清溪向三峡",
515
+ "姑苏城外寒山寺,夜半钟声到客船",
516
+ "东风夜放花千树",
517
+ "夜阑卧听风吹雨",
518
+ "可怜九月初三夜",
519
+ "随风直到夜郎西",
520
+ "烟笼寒水月笼沙,夜泊秦淮近酒家",
521
+ "夜深千帐灯",
522
+ "月黑雁飞高,单于夜遁逃"
523
+ ]),
524
+ 江: Object.freeze([
525
+ "孤帆远影碧空尽,唯见长江天际流",
526
+ "春来江水绿如蓝",
527
+ "春风又绿江南岸",
528
+ "一江春水向东流",
529
+ "天门中断楚江开",
530
+ "寒雨连江夜入吴",
531
+ "竹外桃花三两枝,春江水暖鸭先知",
532
+ "月落乌啼霜满天,江枫渔火对愁眠",
533
+ "江畔何人初见月,江月何年初照人",
534
+ "朝辞白帝彩云间,千里江陵一日还",
535
+ "不尽长江滚滚流",
536
+ "杨柳青青江水平"
537
+ ]),
538
+ 日: Object.freeze([
539
+ "白日依山尽",
540
+ "日照香炉生紫烟",
541
+ "千里江陵一日还",
542
+ "映日荷花别样红",
543
+ "锄禾日当午",
544
+ "日出江花红胜火",
545
+ "海日生残夜",
546
+ "大漠孤烟直,长河落日圆",
547
+ "日暮乡关何处是",
548
+ "浮云游子意,落日故人情",
549
+ "白日放歌须纵酒",
550
+ "闲云潭影日悠悠"
551
+ ]),
552
+ 星: Object.freeze([
553
+ "星垂平野阔",
554
+ "迢迢牵牛星",
555
+ "危楼高百尺,手可摘星辰",
556
+ "微微风簇浪,散作满河星",
557
+ "七八个星天外",
558
+ "天阶夜色凉如水,卧看牵牛织女星",
559
+ "昨夜星辰昨夜风",
560
+ "星汉灿烂,若出其里"
561
+ ]),
562
+ 天: Object.freeze([
563
+ "天街小雨润如酥",
564
+ "黄河之水天上来",
565
+ "飞流直下三千尺,疑是银河落九天",
566
+ "天生我材必有用",
567
+ "天门中断楚江开",
568
+ "江天一色无纤尘",
569
+ "天阶夜色凉如水",
570
+ "天苍苍,野茫茫",
571
+ "接天莲叶无穷碧",
572
+ "蜀道之难,难于上青天",
573
+ "天若有情天亦老"
574
+ ]),
575
+ 柳: Object.freeze([
576
+ "羌笛何须怨杨柳",
577
+ "客舍青青柳色新",
578
+ "两个黄鹂鸣翠柳",
579
+ "柳暗花明又一村",
580
+ "月上柳梢头",
581
+ "春城无处不飞花,寒食东风御柳斜",
582
+ "此夜曲中闻折柳",
583
+ "杨柳岸,晓风残月",
584
+ "枝上柳绵吹又少",
585
+ "最是一年春好处,绝胜烟柳满皇都",
586
+ "芙蓉如面柳如眉",
587
+ "杨柳青青江水平"
588
+ ]),
589
+ 雁: Object.freeze([
590
+ "雁字回时,月满西楼",
591
+ "塞下秋来风景异,衡阳雁去无留意",
592
+ "北风吹雁雪纷纷",
593
+ "征蓬出汉塞,归雁入胡天",
594
+ "月黑雁飞高",
595
+ "雁过也,正伤心,却是旧时相识",
596
+ "秋风起兮白云飞,草木黄落兮雁南归",
597
+ "乡书何处达,归雁洛阳边",
598
+ "何处秋风至,萧萧送雁群"
599
+ ]),
600
+ 烟: Object.freeze([
601
+ "日照香炉生紫烟",
602
+ "大漠孤烟直",
603
+ "烟笼寒水月笼沙",
604
+ "草长莺飞二月天,拂堤杨柳醉春烟",
605
+ "一蓑烟雨任平生",
606
+ "南朝四百八十寺,多少楼台烟雨中",
607
+ "故人西辞黄鹤楼,烟花三月下扬州",
608
+ "日暮乡关何处是,烟波江上使人愁",
609
+ "绿杨烟外晓寒轻",
610
+ "最是一年春好处,绝胜烟柳满皇都"
611
+ ]),
612
+ 楼: Object.freeze([
613
+ "故人西辞黄鹤楼",
614
+ "山外青山楼外楼",
615
+ "小楼昨夜又东风",
616
+ "危楼高百尺",
617
+ "欲穷千里目,更上一层楼",
618
+ "无言独上西楼",
619
+ "近水楼台先得月",
620
+ "昨夜西风凋碧树,独上高楼",
621
+ "楼船夜雪瓜洲渡",
622
+ "雁字回时,月满西楼",
623
+ "多少楼台烟雨中",
624
+ "明月楼高休独倚"
625
+ ]),
626
+ 梦: Object.freeze([
627
+ "夜阑卧听风吹雨,铁马冰河入梦来",
628
+ "梦里不知身是客",
629
+ "人生如梦,一尊还酹江月",
630
+ "庄生晓梦迷蝴蝶",
631
+ "故人入我梦,明我长相忆",
632
+ "夜来幽梦忽还乡",
633
+ "梦回吹角连营",
634
+ "因思杜陵梦,凫雁满回塘"
635
+ ])
636
+ });
637
+ /** 开局/换字时随机抽取的令字池(即诗句库的键)。 */
638
+ const LINGZI_POOL = Object.freeze(Object.keys(POEM_BANK));
639
+ /** 常见(好接)令字:简易/中等难度可选池。 */
640
+ const COMMON_LINGZI = /* @__PURE__ */ new Set([
641
+ "花",
642
+ "月",
643
+ "风",
644
+ "雪",
645
+ "山",
646
+ "水",
647
+ "云",
648
+ "雨",
649
+ "春",
650
+ "秋",
651
+ "人",
652
+ "酒",
653
+ "夜",
654
+ "江",
655
+ "日",
656
+ "星",
657
+ "天"
658
+ ]);
659
+ /** 简易(常见)令字池。 */
660
+ const EASY_LINGZI = Object.freeze(LINGZI_POOL.filter((z) => COMMON_LINGZI.has(z)));
661
+ /** 困难(偏门)令字池。 */
662
+ const HARD_LINGZI = Object.freeze(LINGZI_POOL.filter((z) => !COMMON_LINGZI.has(z)));
663
+ /** 每题限时(秒),按难度递减。 */
664
+ const TIME_LIMIT = {
665
+ easy: 30,
666
+ medium: 20,
667
+ hard: 15
668
+ };
669
+ /** 基础得分:ancient 更严格故更高。 */
670
+ const SCORE_BASE = {
671
+ simple: 10,
672
+ ancient: 15
673
+ };
674
+ /** 连击加成(每段连续 +2)。 */
675
+ const COMBO_BONUS = 2;
676
+ /** 使用提示后本题通过的基础分。 */
677
+ const HINT_SCORE = 5;
678
+ function pick(list) {
679
+ return list[Math.floor(Math.random() * list.length)];
680
+ }
681
+ function clone(state) {
682
+ return {
683
+ ...state,
684
+ usedRaw: [...state.usedRaw],
685
+ usedNormalized: [...state.usedNormalized]
686
+ };
687
+ }
688
+ /** 按难度抽令字:hard 用偏门字池,其余用常见字池。 */
689
+ function pickLingzi(difficulty) {
690
+ const pool = difficulty === "hard" ? HARD_LINGZI : EASY_LINGZI;
691
+ return pick(pool.length > 0 ? pool : LINGZI_POOL);
692
+ }
693
+ /** 诗库中该令字的全部句子。 */
694
+ function bankOf(lingzi) {
695
+ return POEM_BANK[lingzi] ?? [];
696
+ }
697
+ /**
698
+ * 从诗库挑一句未使用的句子(对手出句/提示候选)。
699
+ * @returns 命中返回展示文本,库句被双方用完返回 undefined。
700
+ */
701
+ function pickUnused(state) {
702
+ const candidates = bankOf(state.lingzi).filter((line) => !state.usedNormalized.includes(normalizeLine(line)));
703
+ if (candidates.length === 0) return void 0;
704
+ return pick(candidates);
705
+ }
706
+ /** 创建一局(抽令字 + 对手先手出题)。 */
707
+ function createMatch(options) {
708
+ const lingzi = pickLingzi(options.difficulty);
709
+ const base = {
710
+ phase: "playing",
711
+ mode: options.mode,
712
+ difficulty: options.difficulty,
713
+ lingzi,
714
+ roundIndex: 0,
715
+ solved: 0,
716
+ failures: 0,
717
+ score: 0,
718
+ combo: 0,
719
+ usedRaw: [],
720
+ usedNormalized: [],
721
+ challenge: "",
722
+ hintUsedThisRound: false,
723
+ hintsLeft: 3,
724
+ lastVerdict: "",
725
+ requiredPosition: 1,
726
+ startedAt: Date.now()
727
+ };
728
+ const opener = pickUnused(base);
729
+ if (opener === void 0) return {
730
+ ...base,
731
+ lastVerdict: `令字「${lingzi}」,请出句`
732
+ };
733
+ return {
734
+ ...base,
735
+ challenge: opener,
736
+ usedRaw: [opener],
737
+ usedNormalized: [normalizeForUsed(opener)],
738
+ lastVerdict: `令字「${lingzi}」· 对手出题,请对诗`
739
+ };
740
+ }
741
+ /** 仅规范化用于登记的辅助(与判定同源)。 */
742
+ function normalizeForUsed(raw) {
743
+ return normalizeLine(raw);
744
+ }
745
+ /** 生成"通过"时的结算文案与计分(advance = 是否推进题号)。 */
746
+ function applySolved(state, display) {
747
+ const comboBefore = state.combo;
748
+ const hintPenalty = state.hintUsedThisRound;
749
+ const gain = hintPenalty ? HINT_SCORE : SCORE_BASE[state.mode] + comboBefore * COMBO_BONUS;
750
+ const next = clone(state);
751
+ next.solved += 1;
752
+ next.combo = hintPenalty ? 0 : comboBefore + 1;
753
+ next.score += gain;
754
+ next.usedRaw.push(display);
755
+ next.usedNormalized.push(normalizeForUsed(display));
756
+ if (next.mode === "ancient") next.requiredPosition = next.requiredPosition % 7 + 1;
757
+ next.roundIndex += 1;
758
+ next.hintUsedThisRound = false;
759
+ const streak = next.combo;
760
+ next.lastVerdict = `答对!+${gain}${streak >= 2 ? `(连击 ×${streak})` : ""} · ${next.score} 分`;
761
+ return next;
762
+ }
763
+ /** 推进到下一题(对手换句);无句可出或已到上限即玩家获胜。 */
764
+ function advance(state, why) {
765
+ if (state.roundIndex >= 7) return {
766
+ ...clone(state),
767
+ phase: "won",
768
+ lastVerdict: `通关!连对 ${state.solved} 题,共 ${state.score} 分(${why})`
769
+ };
770
+ const nextLine = pickUnused(state);
771
+ if (nextLine === void 0) {
772
+ const won = clone(state);
773
+ won.phase = "won";
774
+ won.lastVerdict = `对手诗穷,你赢了!共 ${state.score} 分(${why})`;
775
+ return won;
776
+ }
777
+ const next = clone(state);
778
+ next.challenge = nextLine;
779
+ next.usedRaw.push(nextLine);
780
+ next.usedNormalized.push(normalizeForUsed(nextLine));
781
+ next.lastVerdict = `${state.lastVerdict} —— 对手换题`;
782
+ return next;
783
+ }
784
+ /** 玩家提交诗句。 */
785
+ function submitPlayerLine(state, rawText) {
786
+ if (state.phase !== "playing") return {
787
+ state,
788
+ ok: false
789
+ };
790
+ const result = judgePoemAttempt({
791
+ rawText,
792
+ lingzi: state.lingzi,
793
+ mode: state.mode,
794
+ requiredPosition: state.requiredPosition,
795
+ usedNormalized: state.usedNormalized
796
+ });
797
+ if (result.kind === "notPoem") return {
798
+ state: {
799
+ ...state,
800
+ lastVerdict: `这句没有令字「${state.lingzi}」,请重新对诗`
801
+ },
802
+ ok: false
803
+ };
804
+ if (result.kind === "length") return {
805
+ state: {
806
+ ...state,
807
+ lastVerdict: "诗句长度需在 4-48 字之间"
808
+ },
809
+ ok: false
810
+ };
811
+ if (result.kind === "duplicate") return {
812
+ state: {
813
+ ...state,
814
+ lastVerdict: "这句用过了(双方都不能重复),换一句吧"
815
+ },
816
+ ok: false
817
+ };
818
+ if (result.kind === "badPosition") {
819
+ const pos = result.requiredPosition ?? state.requiredPosition;
820
+ const why = result.badPosReason === "tooShort" ? `句子太短:古法要求令字位于第 ${pos} 字` : `位置不对:古法要求第 ${pos} 字是令字「${state.lingzi}」`;
821
+ return {
822
+ state: {
823
+ ...state,
824
+ lastVerdict: why
825
+ },
826
+ ok: false
827
+ };
828
+ }
829
+ return {
830
+ state: advance(applySolved(state, result.display), "答对推进"),
831
+ ok: true
832
+ };
833
+ }
834
+ /** 超时(由面板倒计时触发):本题作废,换题;连续失败达上限判负。 */
835
+ function timeoutRound(state) {
836
+ if (state.phase !== "playing") return state;
837
+ const next = clone(state);
838
+ next.failures += 1;
839
+ next.combo = 0;
840
+ next.hintUsedThisRound = false;
841
+ if (next.failures >= 3) {
842
+ next.phase = "lost";
843
+ next.lastVerdict = `超时 3 次,本局惜败 · 共答对 ${next.solved} 题 / ${next.score} 分`;
844
+ return next;
845
+ }
846
+ next.lastVerdict = `超时(第 ${next.failures}/3 次)—— 换下一题,加油`;
847
+ return advance(next, "超时换题");
848
+ }
849
+ /** 认输/放弃。 */
850
+ function concedeMatch(state) {
851
+ if (state.phase !== "playing") return state;
852
+ const next = clone(state);
853
+ next.phase = "lost";
854
+ next.lastVerdict = `你认输了 · 共答对 ${next.solved} 题 / ${next.score} 分`;
855
+ return next;
856
+ }
857
+ /** 提示:消耗 1 次,给出库中一句未用参考诗(可直接跟对,但只计 HINT_SCORE 且断连击)。 */
858
+ function useHint(state) {
859
+ if (state.phase !== "playing" || state.hintsLeft <= 0) return { state };
860
+ const next = clone(state);
861
+ next.hintsLeft -= 1;
862
+ next.hintUsedThisRound = true;
863
+ const line = pickUnused(next);
864
+ next.lastVerdict = line ? `提示(剩 ${next.hintsLeft} 次):参考「${line}」—— 参考句直接对出只 +${HINT_SCORE} 分` : `提示(剩 ${next.hintsLeft} 次):想一句含「${state.lingzi}」的诗句吧`;
865
+ return {
866
+ state: next,
867
+ line
868
+ };
869
+ }
870
+ //#endregion
871
+ //#region node_modules/.pnpm/react@19.2.8/node_modules/react/cjs/react-jsx-runtime.production.js
872
+ /**
873
+ * @license React
874
+ * react-jsx-runtime.production.js
875
+ *
876
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
877
+ *
878
+ * This source code is licensed under the MIT license found in the
879
+ * LICENSE file in the root directory of this source tree.
880
+ */
881
+ var require_react_jsx_runtime_production = /* @__PURE__ */ __commonJSMin(((exports) => {
882
+ var REACT_ELEMENT_TYPE = Symbol.for("react.transitional.element");
883
+ function jsxProd(type, config, maybeKey) {
884
+ var key = null;
885
+ void 0 !== maybeKey && (key = "" + maybeKey);
886
+ void 0 !== config.key && (key = "" + config.key);
887
+ if ("key" in config) {
888
+ maybeKey = {};
889
+ for (var propName in config) "key" !== propName && (maybeKey[propName] = config[propName]);
890
+ } else maybeKey = config;
891
+ config = maybeKey.ref;
892
+ return {
893
+ $$typeof: REACT_ELEMENT_TYPE,
894
+ type,
895
+ key,
896
+ ref: void 0 !== config ? config : null,
897
+ props: maybeKey
898
+ };
899
+ }
900
+ exports.jsx = jsxProd;
901
+ exports.jsxs = jsxProd;
902
+ }));
903
+ //#endregion
904
+ //#region src/client/panel.tsx
905
+ /**
906
+ * 浮层游戏面板(完整对战 UI)。
907
+ * 对局状态由 HeaderButton 层持有(useGamePanel),本组件只做展示与事件转发:
908
+ * 关闭面板只隐藏,不丢对局;隐藏期间倒计时暂停(视同暂停)。
909
+ * @module dsh-plugin-feihualing/client/panel
910
+ */
911
+ var import_jsx_runtime = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
912
+ module.exports = require_react_jsx_runtime_production();
913
+ })))();
914
+ /** 会话短标识(展示用)。 */
915
+ function shortSession(sessionId) {
916
+ return sessionId.length > 12 ? `${sessionId.slice(0, 10)}…` : sessionId;
917
+ }
918
+ const MODE_LABEL = {
919
+ simple: "简易",
920
+ ancient: "古法"
921
+ };
922
+ const DIFF_LABEL = {
923
+ easy: "轻松",
924
+ medium: "标准",
925
+ hard: "困难"
926
+ };
927
+ const DIFF_HINT = {
928
+ easy: "常见令字 · 30s",
929
+ medium: "常见令字 · 20s",
930
+ hard: "冷门令字 · 15s"
931
+ };
932
+ /** 开局设置行(未开局或对局结束后显示)。 */
933
+ function SetupView(props) {
934
+ const { api } = props;
935
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(react.default.Fragment, { children: [
936
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
937
+ className: "fhl-row",
938
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
939
+ className: "fhl-seg-label",
940
+ children: "模式"
941
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
942
+ className: "fhl-seg",
943
+ children: ["simple", "ancient"].map((m) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
944
+ type: "button",
945
+ className: `fhl-seg-item${api.mode === m ? " is-active" : ""}`,
946
+ onClick: () => api.setMode(m),
947
+ children: MODE_LABEL[m]
948
+ }, m))
949
+ })]
950
+ }),
951
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
952
+ className: "fhl-row",
953
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
954
+ className: "fhl-seg-label",
955
+ children: "难度"
956
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
957
+ className: "fhl-seg",
958
+ children: [
959
+ "easy",
960
+ "medium",
961
+ "hard"
962
+ ].map((d) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
963
+ type: "button",
964
+ className: `fhl-seg-item${api.difficulty === d ? " is-active" : ""}`,
965
+ onClick: () => api.setDifficulty(d),
966
+ title: DIFF_HINT[d],
967
+ children: DIFF_LABEL[d]
968
+ }, d))
969
+ })]
970
+ }),
971
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
972
+ className: "fhl-muted",
973
+ style: {
974
+ fontSize: 12,
975
+ textAlign: "center",
976
+ paddingBottom: 4
977
+ },
978
+ children: [
979
+ api.mode === "ancient" ? "古法:令字须按位置循环(第 1 字 → 第 7 字)" : "简易:诗句任意位置含令字即可",
980
+ " · ",
981
+ DIFF_HINT[api.difficulty]
982
+ ]
983
+ }),
984
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
985
+ type: "button",
986
+ className: "fhl-btn fhl-btn-primary",
987
+ onClick: api.start,
988
+ children: "🎴 开始对局"
989
+ }),
990
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
991
+ className: "fhl-muted",
992
+ style: {
993
+ fontSize: 11,
994
+ textAlign: "center"
995
+ },
996
+ children: [
997
+ "对手出题 · 你限时对诗 · 连对 ",
998
+ 7,
999
+ " 题通关 · 超时 ",
1000
+ 3,
1001
+ " 次惜败"
1002
+ ]
1003
+ })
1004
+ ] });
1005
+ }
1006
+ /** 进行中对局视图。 */
1007
+ function PlayView(props) {
1008
+ const { api } = props;
1009
+ const match = api.match;
1010
+ if (match === null) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(react.default.Fragment, {});
1011
+ const timeLow = api.secondsLeft <= 5;
1012
+ const canAct = match.phase === "playing";
1013
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(react.default.Fragment, { children: [
1014
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1015
+ className: "fhl-stage",
1016
+ children: [
1017
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
1018
+ className: "fhl-stage-round",
1019
+ children: [
1020
+ "第 ",
1021
+ match.roundIndex + 1,
1022
+ "/",
1023
+ 7,
1024
+ " 题"
1025
+ ]
1026
+ }),
1027
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
1028
+ className: `fhl-timer${timeLow && canAct ? " is-low" : ""}`,
1029
+ children: [api.secondsLeft, "s"]
1030
+ }),
1031
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
1032
+ className: "fhl-stage-tag",
1033
+ children: [
1034
+ MODE_LABEL[match.mode],
1035
+ " · ",
1036
+ DIFF_LABEL[match.difficulty]
1037
+ ]
1038
+ })
1039
+ ]
1040
+ }),
1041
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1042
+ className: "fhl-lingzi",
1043
+ children: [
1044
+ "「",
1045
+ match.lingzi,
1046
+ "」"
1047
+ ]
1048
+ }),
1049
+ match.mode === "ancient" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1050
+ className: "fhl-muted",
1051
+ style: {
1052
+ fontSize: 11,
1053
+ textAlign: "center",
1054
+ marginTop: -4
1055
+ },
1056
+ children: [
1057
+ "古法:本轮令字须位于第 ",
1058
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("b", {
1059
+ style: { color: "var(--fhl-accent)" },
1060
+ children: match.requiredPosition
1061
+ }),
1062
+ " 字"
1063
+ ]
1064
+ }),
1065
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1066
+ className: "fhl-card fhl-card-bot",
1067
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
1068
+ className: "fhl-card-role",
1069
+ children: "🤖 对手"
1070
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
1071
+ className: "fhl-card-line",
1072
+ children: match.challenge
1073
+ })]
1074
+ }),
1075
+ match.lastVerdict !== "" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
1076
+ className: `fhl-verdict${api.secondsLeft === 0 || !canAct ? "" : ""}`,
1077
+ children: match.lastVerdict
1078
+ }),
1079
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("form", {
1080
+ className: "fhl-input-row",
1081
+ onSubmit: (ev) => {
1082
+ ev.preventDefault();
1083
+ api.submit();
1084
+ },
1085
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
1086
+ className: "fhl-input",
1087
+ value: api.draft,
1088
+ onChange: (ev) => api.setDraft(ev.target.value),
1089
+ placeholder: `说一句含「${match.lingzi}」的诗句…`,
1090
+ disabled: !canAct,
1091
+ autoComplete: "off"
1092
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
1093
+ type: "submit",
1094
+ className: "fhl-btn fhl-btn-primary",
1095
+ disabled: !canAct,
1096
+ children: "对诗"
1097
+ })]
1098
+ }),
1099
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1100
+ className: "fhl-actions",
1101
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", {
1102
+ type: "button",
1103
+ className: "fhl-btn",
1104
+ onClick: api.requestHint,
1105
+ disabled: !canAct || match.hintsLeft <= 0,
1106
+ title: "消耗提示次数,给出参考诗句(参考句直接跟对只得 5 分)",
1107
+ children: [
1108
+ "💡 提示(",
1109
+ match.hintsLeft,
1110
+ ")"
1111
+ ]
1112
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
1113
+ type: "button",
1114
+ className: "fhl-btn fhl-btn-danger",
1115
+ onClick: api.concede,
1116
+ disabled: !canAct,
1117
+ children: "认输"
1118
+ })]
1119
+ }),
1120
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1121
+ className: "fhl-stats",
1122
+ children: [
1123
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
1124
+ className: "fhl-stat",
1125
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("b", { children: match.score }), " 得分"]
1126
+ }),
1127
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
1128
+ className: "fhl-stat",
1129
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("b", { children: match.combo }), " 连击"]
1130
+ }),
1131
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
1132
+ className: "fhl-stat",
1133
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("b", { children: [
1134
+ match.solved,
1135
+ "/",
1136
+ 7
1137
+ ] }), " 答对"]
1138
+ }),
1139
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
1140
+ className: `fhl-stat${match.failures >= 2 ? " is-warn" : ""}`,
1141
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("b", { children: [
1142
+ match.failures,
1143
+ "/",
1144
+ 3
1145
+ ] }), " 超时"]
1146
+ })
1147
+ ]
1148
+ })
1149
+ ] });
1150
+ }
1151
+ /** 结算视图。 */
1152
+ function EndView(props) {
1153
+ const { api } = props;
1154
+ const match = api.match;
1155
+ if (match === null) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(react.default.Fragment, {});
1156
+ const won = match.phase === "won";
1157
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(react.default.Fragment, { children: [
1158
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1159
+ className: `fhl-result${won ? " is-won" : " is-lost"}`,
1160
+ children: [
1161
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
1162
+ className: "fhl-result-icon",
1163
+ children: won ? "🏆" : "😢"
1164
+ }),
1165
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
1166
+ className: "fhl-result-title",
1167
+ children: won ? "通关!" : "本局惜败"
1168
+ }),
1169
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
1170
+ className: "fhl-muted",
1171
+ style: {
1172
+ fontSize: 12,
1173
+ textAlign: "center"
1174
+ },
1175
+ children: match.lastVerdict
1176
+ })
1177
+ ]
1178
+ }),
1179
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1180
+ className: "fhl-stats",
1181
+ children: [
1182
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
1183
+ className: "fhl-stat",
1184
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("b", { children: match.score }), " 得分"]
1185
+ }),
1186
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
1187
+ className: "fhl-stat",
1188
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("b", { children: match.solved }), " 答对"]
1189
+ }),
1190
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
1191
+ className: "fhl-stat",
1192
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("b", { children: match.failures }), " 超时"]
1193
+ })
1194
+ ]
1195
+ }),
1196
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1197
+ className: "fhl-record",
1198
+ children: [
1199
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
1200
+ "🏅 ",
1201
+ api.stats.wins,
1202
+ " 胜 / ",
1203
+ api.stats.losses,
1204
+ " 负"
1205
+ ] }),
1206
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: ["🔥 连胜 ", api.stats.streak] }),
1207
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: ["✨ 最佳 ", api.stats.bestScore] })
1208
+ ]
1209
+ }),
1210
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
1211
+ type: "button",
1212
+ className: "fhl-btn fhl-btn-primary",
1213
+ onClick: api.start,
1214
+ children: "🔄 再来一局"
1215
+ }),
1216
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1217
+ className: "fhl-muted",
1218
+ style: {
1219
+ fontSize: 11,
1220
+ textAlign: "center"
1221
+ },
1222
+ children: [
1223
+ "令字「",
1224
+ match.lingzi,
1225
+ "」· ",
1226
+ MODE_LABEL[match.mode],
1227
+ " · ",
1228
+ DIFF_LABEL[match.difficulty]
1229
+ ]
1230
+ })
1231
+ ] });
1232
+ }
1233
+ /** 面板主体:按对局阶段切换视图。 */
1234
+ function GamePanel(props) {
1235
+ const { sessionId, onClose, api } = props;
1236
+ const phase = api.match === null ? "setup" : api.match.phase;
1237
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1238
+ className: "fhl-root fhl-panel",
1239
+ "data-plugin": "dsh-plugin-feihualing",
1240
+ children: [
1241
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1242
+ className: "fhl-panel-header",
1243
+ children: [
1244
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
1245
+ className: "fhl-panel-title",
1246
+ children: "🎴 飞花令 · 即时对战"
1247
+ }),
1248
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
1249
+ className: "fhl-tag",
1250
+ title: sessionId,
1251
+ children: ["会话 ", shortSession(sessionId)]
1252
+ }),
1253
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
1254
+ type: "button",
1255
+ className: "fhl-panel-close",
1256
+ onClick: onClose,
1257
+ title: "收起(对局保留)",
1258
+ children: "✕"
1259
+ })
1260
+ ]
1261
+ }),
1262
+ phase === "setup" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SetupView, { api }),
1263
+ phase === "playing" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PlayView, { api }),
1264
+ (phase === "won" || phase === "lost") && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(EndView, { api })
1265
+ ]
1266
+ });
1267
+ }
1268
+ //#endregion
1269
+ //#region src/client/game/stats.ts
1270
+ const KEY = "dsh-plugin-feihualing:stats";
1271
+ function empty() {
1272
+ return {
1273
+ wins: 0,
1274
+ losses: 0,
1275
+ bestScore: 0,
1276
+ streak: 0,
1277
+ bestStreak: 0,
1278
+ solvedTotal: 0
1279
+ };
1280
+ }
1281
+ /** 读取战绩(损坏/缺失返回空战绩)。 */
1282
+ function loadStats() {
1283
+ try {
1284
+ const raw = localStorage.getItem(KEY);
1285
+ if (raw === null) return empty();
1286
+ const parsed = JSON.parse(raw);
1287
+ return {
1288
+ wins: typeof parsed.wins === "number" ? parsed.wins : 0,
1289
+ losses: typeof parsed.losses === "number" ? parsed.losses : 0,
1290
+ bestScore: typeof parsed.bestScore === "number" ? parsed.bestScore : 0,
1291
+ streak: typeof parsed.streak === "number" ? parsed.streak : 0,
1292
+ bestStreak: typeof parsed.bestStreak === "number" ? parsed.bestStreak : 0,
1293
+ solvedTotal: typeof parsed.solvedTotal === "number" ? parsed.solvedTotal : 0
1294
+ };
1295
+ } catch {
1296
+ return empty();
1297
+ }
1298
+ }
1299
+ /** 结算一局并入账(返回并持久化新战绩)。 */
1300
+ function recordResult(stats, match) {
1301
+ const won = match.phase === "won";
1302
+ const next = {
1303
+ ...stats,
1304
+ wins: stats.wins + (won ? 1 : 0),
1305
+ losses: stats.losses + (won ? 0 : 1),
1306
+ bestScore: Math.max(stats.bestScore, match.score),
1307
+ streak: won ? stats.streak + 1 : 0,
1308
+ bestStreak: Math.max(stats.bestStreak, won ? stats.streak + 1 : stats.bestStreak),
1309
+ solvedTotal: stats.solvedTotal + match.solved
1310
+ };
1311
+ try {
1312
+ localStorage.setItem(KEY, JSON.stringify(next));
1313
+ } catch {}
1314
+ return next;
1315
+ }
1316
+ //#endregion
1317
+ //#region src/client/game/useGame.ts
1318
+ /**
1319
+ * React 对局 hook:包装 engine 的纯逻辑,管理倒计时与面板动作。
1320
+ * 倒计时由 useGamePanel 内部控制(setInterval,无第三方库)。
1321
+ * @module dsh-plugin-feihualing/client/game/useGame
1322
+ */
1323
+ /**
1324
+ * 一局对战面板状态。倒计时只在对局进行中且面板激活时跑;
1325
+ * 面板隐藏(active=false)视同暂停:计时停住、不超时,重开面板继续。
1326
+ * 答题推进/超时换题时按 (phase, roundIndex, challenge) 变化重置计时。
1327
+ * @param active 面板是否可见(决定倒计时是否运行)。
1328
+ */
1329
+ function useGamePanel(active) {
1330
+ const [match, setMatch] = react.default.useState(null);
1331
+ const [secondsLeft, setSecondsLeft] = react.default.useState(0);
1332
+ const [mode, setMode] = react.default.useState("simple");
1333
+ const [difficulty, setDifficulty] = react.default.useState("medium");
1334
+ const [draft, setDraft] = react.default.useState("");
1335
+ const [stats, setStats] = react.default.useState(() => loadStats());
1336
+ const recordedStartedAt = react.default.useRef(null);
1337
+ react.default.useEffect(() => {
1338
+ if (match === null || match.phase !== "won" && match.phase !== "lost") return;
1339
+ if (recordedStartedAt.current === match.startedAt) return;
1340
+ recordedStartedAt.current = match.startedAt;
1341
+ setStats((cur) => recordResult(cur, match));
1342
+ }, [match]);
1343
+ const roundKey = match === null ? "" : `${match.phase}:${match.roundIndex}:${match.challenge}`;
1344
+ react.default.useEffect(() => {
1345
+ if (match === null || match.phase !== "playing") return;
1346
+ setSecondsLeft(TIME_LIMIT[match.difficulty]);
1347
+ }, [roundKey]);
1348
+ react.default.useEffect(() => {
1349
+ if (!active || match === null || match.phase !== "playing") return;
1350
+ const timer = setInterval(() => {
1351
+ setSecondsLeft((s) => Math.max(0, s - 1));
1352
+ }, 1e3);
1353
+ return () => clearInterval(timer);
1354
+ }, [active, roundKey]);
1355
+ react.default.useEffect(() => {
1356
+ if (!active || match === null || match.phase !== "playing") return;
1357
+ if (secondsLeft > 0) return;
1358
+ setMatch((cur) => cur !== null && cur.phase === "playing" ? timeoutRound(cur) : cur);
1359
+ }, [secondsLeft, active]);
1360
+ return {
1361
+ match,
1362
+ secondsLeft,
1363
+ mode,
1364
+ difficulty,
1365
+ draft,
1366
+ stats,
1367
+ setMode,
1368
+ setDifficulty,
1369
+ setDraft,
1370
+ start: react.default.useCallback(() => {
1371
+ setMatch(createMatch({
1372
+ mode,
1373
+ difficulty
1374
+ }));
1375
+ setDraft("");
1376
+ }, [mode, difficulty]),
1377
+ submit: react.default.useCallback(() => {
1378
+ const text = draft.trim();
1379
+ if (text.length === 0) return;
1380
+ setMatch((cur) => {
1381
+ if (cur === null || cur.phase !== "playing") return cur;
1382
+ return submitPlayerLine(cur, text).state;
1383
+ });
1384
+ setDraft("");
1385
+ }, [draft]),
1386
+ requestHint: react.default.useCallback(() => {
1387
+ setMatch((cur) => {
1388
+ if (cur === null || cur.phase !== "playing") return cur;
1389
+ return useHint(cur).state;
1390
+ });
1391
+ }, []),
1392
+ concede: react.default.useCallback(() => {
1393
+ setMatch((cur) => cur === null ? cur : concedeMatch(cur));
1394
+ }, [])
1395
+ };
1396
+ }
1397
+ //#endregion
1398
+ //#region src/client/header-button.tsx
1399
+ /**
1400
+ * 会话标题操作按钮(conversation.session.header.actions 插槽,session 级 list)。
1401
+ * 点击后在 body 上挂载游戏浮层面板(portal,自带 surface 作用域,不触碰宿主 DOM)。
1402
+ * 面板的开合状态按组件实例维护;组件卸载(会话视图关闭)时同步收起面板。
1403
+ * @module dsh-plugin-feihualing/client/header-button
1404
+ */
1405
+ /** 在会话头操作行注册游戏开关按钮。 */
1406
+ function registerHeaderButton(ctx) {
1407
+ ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
1408
+ name: "conversation.session.header.actions",
1409
+ id: NAMESPACE,
1410
+ order: 90,
1411
+ inject: (sessionId) => ({ sessionId })
1412
+ }, HeaderButton));
1413
+ }
1414
+ /** 会话头游戏开关按钮:对局状态常驻本组件(关面板不丢局),隐藏期间倒计时暂停。 */
1415
+ function HeaderButton(props) {
1416
+ const [open, setOpen] = react.default.useState(false);
1417
+ const [host] = react.default.useState(() => {
1418
+ const el = document.createElement("div");
1419
+ el.setAttribute("data-plugin", "dsh-plugin-feihualing");
1420
+ el.setAttribute("data-fhl-portal", "");
1421
+ document.body.appendChild(el);
1422
+ return el;
1423
+ });
1424
+ const api = useGamePanel(open);
1425
+ react.default.useEffect(() => {
1426
+ return () => {
1427
+ setOpen(false);
1428
+ if (host && host.parentNode) host.parentNode.removeChild(host);
1429
+ };
1430
+ }, []);
1431
+ const sessionId = props.sessionId ?? "";
1432
+ const toggle = react.default.useCallback(() => setOpen((prev) => !prev), []);
1433
+ return react.default.createElement(react.default.Fragment, null, react.default.createElement("button", {
1434
+ type: "button",
1435
+ className: "fhl-toggle",
1436
+ "data-active": open ? "true" : "false",
1437
+ onClick: toggle,
1438
+ title: open ? "收起飞花令(对局保留)" : "飞花令小游戏",
1439
+ "aria-label": "飞花令小游戏"
1440
+ }, "🎴"), host !== null && open ? (0, react_dom.createPortal)(react.default.createElement(GamePanel, {
1441
+ sessionId,
1442
+ onClose: () => setOpen(false),
1443
+ api
1444
+ }), host) : null);
1445
+ }
1446
+ //#endregion
1447
+ //#region src/client/index.ts
1448
+ /** 依赖的服务:slots 就绪后本插件才会加载。 */
1449
+ const inject = ["slots"];
1450
+ /** 客户端插件主体。 */
1451
+ function apply(ctx) {
1452
+ const effect = ctx.effect;
1453
+ const disposeStyles = injectStyles();
1454
+ if (effect) effect(() => disposeStyles, "dsh-plugin-feihualing: client styles");
1455
+ registerHeaderButton(ctx);
1456
+ }
1457
+ //#endregion
1458
+ exports.apply = apply;
1459
+ exports.inject = inject;
1460
+ return module.exports;
1461
+ }
1462
+ });
1463
+
1464
+ //# sourceMappingURL=client.js.map