@zhin.js/plugin-word-riddle 1.0.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 +20 -0
- package/lib/commands.d.ts +6 -0
- package/lib/commands.d.ts.map +1 -0
- package/lib/commands.js +94 -0
- package/lib/commands.js.map +1 -0
- package/lib/data/char-riddles.json +1 -0
- package/lib/data/riddles.d.ts +3 -0
- package/lib/data/riddles.d.ts.map +1 -0
- package/lib/data/riddles.js +2 -0
- package/lib/data/riddles.js.map +1 -0
- package/lib/engine.d.ts +9 -0
- package/lib/engine.d.ts.map +1 -0
- package/lib/engine.js +20 -0
- package/lib/engine.js.map +1 -0
- package/lib/game-flow.d.ts +12 -0
- package/lib/game-flow.d.ts.map +1 -0
- package/lib/game-flow.js +141 -0
- package/lib/game-flow.js.map +1 -0
- package/lib/hub-register.d.ts +5 -0
- package/lib/hub-register.d.ts.map +1 -0
- package/lib/hub-register.js +32 -0
- package/lib/hub-register.js.map +1 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +26 -0
- package/lib/index.js.map +1 -0
- package/lib/models.d.ts +31 -0
- package/lib/models.d.ts.map +1 -0
- package/lib/models.js +25 -0
- package/lib/models.js.map +1 -0
- package/lib/riddle-command.d.ts +5 -0
- package/lib/riddle-command.d.ts.map +1 -0
- package/lib/riddle-command.js +56 -0
- package/lib/riddle-command.js.map +1 -0
- package/lib/riddle-provider.d.ts +21 -0
- package/lib/riddle-provider.d.ts.map +1 -0
- package/lib/riddle-provider.js +80 -0
- package/lib/riddle-provider.js.map +1 -0
- package/lib/session-service.d.ts +20 -0
- package/lib/session-service.d.ts.map +1 -0
- package/lib/session-service.js +108 -0
- package/lib/session-service.js.map +1 -0
- package/lib/view.d.ts +6 -0
- package/lib/view.d.ts.map +1 -0
- package/lib/view.js +50 -0
- package/lib/view.js.map +1 -0
- package/package.json +39 -0
- package/plugin.yml +2 -0
- package/src/commands.ts +112 -0
- package/src/data/char-riddles.json +1 -0
- package/src/data/riddles.ts +8 -0
- package/src/engine.ts +30 -0
- package/src/game-flow.ts +196 -0
- package/src/hub-register.ts +34 -0
- package/src/index.ts +32 -0
- package/src/models.ts +57 -0
- package/src/riddle-command.ts +66 -0
- package/src/riddle-provider.ts +105 -0
- package/src/session-service.ts +113 -0
- package/src/view.ts +60 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getRiddleById,
|
|
3
|
+
type RiddleEntry,
|
|
4
|
+
type RiddleType,
|
|
5
|
+
} from './riddle-provider.js';
|
|
6
|
+
|
|
7
|
+
export type { RiddleType };
|
|
8
|
+
|
|
9
|
+
export const RIDDLE_PREFIX = 'riddle';
|
|
10
|
+
|
|
11
|
+
export function normalizeAnswer(raw: string): string {
|
|
12
|
+
return raw.trim().replace(/\s/g, '').replace(/[。.!!??,,]/g, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function answersFor(entry: RiddleEntry): string[] {
|
|
16
|
+
const all = [entry.answer, ...(entry.aliases ?? [])];
|
|
17
|
+
return [...new Set(all.map(normalizeAnswer))];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function checkAnswer(entry: RiddleEntry, raw: string): boolean {
|
|
21
|
+
const a = normalizeAnswer(raw);
|
|
22
|
+
if (!a) return false;
|
|
23
|
+
return answersFor(entry).some((x) => x === a);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function typeLabel(type: RiddleType): string {
|
|
27
|
+
return type === 'char' ? '字谜' : '猜成语';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { getRiddleById };
|
package/src/game-flow.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { Adapter, Message, Plugin } from 'zhin.js';
|
|
2
|
+
import {
|
|
3
|
+
checkAnswer,
|
|
4
|
+
getRiddleById,
|
|
5
|
+
RIDDLE_PREFIX,
|
|
6
|
+
typeLabel,
|
|
7
|
+
} from './engine.js';
|
|
8
|
+
import type { RiddleSessionRow } from './models.js';
|
|
9
|
+
import type { RiddleType } from './data/riddles.js';
|
|
10
|
+
import {
|
|
11
|
+
currentRiddleId,
|
|
12
|
+
parseQueue,
|
|
13
|
+
type SessionService,
|
|
14
|
+
} from './session-service.js';
|
|
15
|
+
import { buildRiddleView, MAX_WRONG } from './view.js';
|
|
16
|
+
|
|
17
|
+
export async function sendOrEditView(
|
|
18
|
+
plugin: Plugin,
|
|
19
|
+
services: SessionService,
|
|
20
|
+
message: Message<any>,
|
|
21
|
+
session: RiddleSessionRow,
|
|
22
|
+
eventLines: string[] = [],
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
const content = buildRiddleView(session, eventLines);
|
|
25
|
+
if (typeof content === 'string') {
|
|
26
|
+
await message.$reply?.(content);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const adapter = plugin.root.inject(message.$adapter) as Adapter;
|
|
31
|
+
if (session.board_message_id) {
|
|
32
|
+
const msgId = await adapter.editMessage({
|
|
33
|
+
messageId: session.board_message_id,
|
|
34
|
+
context: String(message.$adapter),
|
|
35
|
+
endpoint: message.$endpoint,
|
|
36
|
+
id: message.$channel.id,
|
|
37
|
+
type: message.$channel.type,
|
|
38
|
+
content,
|
|
39
|
+
});
|
|
40
|
+
if (msgId !== session.board_message_id) {
|
|
41
|
+
await services.updateSession(session.id, { board_message_id: msgId });
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const msgId = await message.$reply?.(content);
|
|
47
|
+
if (msgId) await services.updateSession(session.id, { board_message_id: msgId });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function advanceQuestion(
|
|
51
|
+
services: SessionService,
|
|
52
|
+
session: RiddleSessionRow,
|
|
53
|
+
): Promise<RiddleSessionRow> {
|
|
54
|
+
const queue = parseQueue(session.queue);
|
|
55
|
+
const nextIndex = session.index + 1;
|
|
56
|
+
if (nextIndex >= queue.length) {
|
|
57
|
+
await services.updateSession(session.id, { index: nextIndex, status: 'completed', wrong_count: 0 });
|
|
58
|
+
} else {
|
|
59
|
+
await services.updateSession(session.id, { index: nextIndex, wrong_count: 0 });
|
|
60
|
+
}
|
|
61
|
+
return (await services.getById(session.id))!;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function startGame(
|
|
65
|
+
plugin: Plugin,
|
|
66
|
+
services: SessionService,
|
|
67
|
+
message: Message<any>,
|
|
68
|
+
mode: RiddleType,
|
|
69
|
+
): Promise<string | undefined> {
|
|
70
|
+
const ch = `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
|
|
71
|
+
const active = await services.getActiveByChannel(ch);
|
|
72
|
+
if (active) {
|
|
73
|
+
if (active.player_id === message.$sender.id) {
|
|
74
|
+
return `你已有进行中的猜谜(${typeLabel(active.mode as RiddleType)}),发送「猜谜 继续」刷新。`;
|
|
75
|
+
}
|
|
76
|
+
return `本频道 ${active.player_name} 正在猜谜。`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const session = await services.createSession(message, mode);
|
|
80
|
+
await sendOrEditView(plugin, services, message, session);
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function continueGame(
|
|
85
|
+
plugin: Plugin,
|
|
86
|
+
services: SessionService,
|
|
87
|
+
message: Message<any>,
|
|
88
|
+
): Promise<string> {
|
|
89
|
+
const session = await services.getActiveForUser(
|
|
90
|
+
`${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`,
|
|
91
|
+
message.$sender.id,
|
|
92
|
+
);
|
|
93
|
+
if (!session) return '你没有进行中的猜谜,发送「猜谜 开始」。';
|
|
94
|
+
await sendOrEditView(plugin, services, message, session);
|
|
95
|
+
return '已刷新猜谜界面。';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function processAnswerText(
|
|
99
|
+
plugin: Plugin,
|
|
100
|
+
services: SessionService,
|
|
101
|
+
message: Message<any>,
|
|
102
|
+
raw: string,
|
|
103
|
+
): Promise<string | null> {
|
|
104
|
+
const ch = `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
|
|
105
|
+
const session = await services.getActiveForUser(ch, message.$sender.id);
|
|
106
|
+
if (!session || session.status !== 'active') return null;
|
|
107
|
+
|
|
108
|
+
const riddleId = currentRiddleId(session);
|
|
109
|
+
const entry = riddleId ? getRiddleById(riddleId) : undefined;
|
|
110
|
+
if (!entry) return null;
|
|
111
|
+
|
|
112
|
+
if (checkAnswer(entry, raw)) {
|
|
113
|
+
const streak = session.streak + 1;
|
|
114
|
+
const best = Math.max(session.best_streak, streak);
|
|
115
|
+
await services.updateSession(session.id, {
|
|
116
|
+
score: session.score + 10 + Math.min(streak, 5),
|
|
117
|
+
streak,
|
|
118
|
+
best_streak: best,
|
|
119
|
+
wrong_count: 0,
|
|
120
|
+
});
|
|
121
|
+
const after = await advanceQuestion(services, (await services.getById(session.id))!);
|
|
122
|
+
const explain = entry.explanation ? `\n📖 ${entry.explanation}` : '';
|
|
123
|
+
await sendOrEditView(plugin, services, message, after, [
|
|
124
|
+
`✅ 正确!答案:**${entry.answer}**${explain}`,
|
|
125
|
+
`+${10 + Math.min(streak, 5)} 分`,
|
|
126
|
+
]);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const wrong = session.wrong_count + 1;
|
|
131
|
+
if (wrong >= MAX_WRONG) {
|
|
132
|
+
await services.updateSession(session.id, { wrong_count: wrong, streak: 0 });
|
|
133
|
+
const after = await advanceQuestion(services, (await services.getById(session.id))!);
|
|
134
|
+
await sendOrEditView(plugin, services, message, after, [
|
|
135
|
+
`❌ 本题答案:**${entry.answer}**`,
|
|
136
|
+
'失误过多,自动下一题。',
|
|
137
|
+
]);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await services.updateSession(session.id, { wrong_count: wrong, streak: 0 });
|
|
142
|
+
const updated = (await services.getById(session.id))!;
|
|
143
|
+
await sendOrEditView(plugin, services, message, updated, ['❌ 不对,再想想!']);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function handleChoice(
|
|
148
|
+
plugin: Plugin,
|
|
149
|
+
services: SessionService,
|
|
150
|
+
message: Message<any>,
|
|
151
|
+
sessionId: string,
|
|
152
|
+
choiceId: string,
|
|
153
|
+
): Promise<string | null> {
|
|
154
|
+
const session = await services.getById(sessionId);
|
|
155
|
+
if (!session) return '会话不存在。';
|
|
156
|
+
if (session.player_id !== message.$sender.id) return '这是别人的猜谜。';
|
|
157
|
+
|
|
158
|
+
if (choiceId === 'restart_char') {
|
|
159
|
+
await services.updateSession(session.id, { status: 'aborted' });
|
|
160
|
+
return startGame(plugin, services, message, 'char') as unknown as string;
|
|
161
|
+
}
|
|
162
|
+
if (choiceId === 'restart_idiom') {
|
|
163
|
+
await services.updateSession(session.id, { status: 'aborted' });
|
|
164
|
+
return startGame(plugin, services, message, 'idiom') as unknown as string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (session.status !== 'active') return '本轮已结束。';
|
|
168
|
+
|
|
169
|
+
const riddleId = currentRiddleId(session);
|
|
170
|
+
const entry = riddleId ? getRiddleById(riddleId) : undefined;
|
|
171
|
+
if (!entry) return '题目丢失。';
|
|
172
|
+
|
|
173
|
+
if (choiceId === 'hint') {
|
|
174
|
+
const hint = entry.hint ?? `答案共 ${entry.answer.length} 个字`;
|
|
175
|
+
await services.updateSession(session.id, { hints_used: session.hints_used + 1, streak: 0 });
|
|
176
|
+
const updated = (await services.getById(session.id))!;
|
|
177
|
+
await sendOrEditView(plugin, services, message, updated, [`💡 提示:${hint}`, '(连击清零)']);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (choiceId === 'skip') {
|
|
182
|
+
await services.updateSession(session.id, { streak: 0 });
|
|
183
|
+
const after = await advanceQuestion(services, session);
|
|
184
|
+
await sendOrEditView(plugin, services, message, after, [`⏭️ 跳过,答案:**${entry.answer}**`]);
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (choiceId === 'quit') {
|
|
189
|
+
await services.updateSession(session.id, { status: 'aborted' });
|
|
190
|
+
return '已结束猜谜。';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return '未知操作。';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export { RIDDLE_PREFIX };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { getPlugin } from 'zhin.js';
|
|
2
|
+
import { ensureGameHubService } from '@zhin.js/game-shared';
|
|
3
|
+
import { riddleCount } from './data/riddles.js';
|
|
4
|
+
import { runRiddleCommand, RIDDLE_HELP } from './riddle-command.js';
|
|
5
|
+
import type { SessionService } from './session-service.js';
|
|
6
|
+
|
|
7
|
+
const counts = riddleCount();
|
|
8
|
+
|
|
9
|
+
export function registerRiddleHub(getServices: () => SessionService | null): () => void {
|
|
10
|
+
const plugin = getPlugin();
|
|
11
|
+
ensureGameHubService(plugin);
|
|
12
|
+
return plugin.registerGame({
|
|
13
|
+
id: 'riddle',
|
|
14
|
+
title: '猜谜',
|
|
15
|
+
icon: '🧩',
|
|
16
|
+
description: `字谜 ${counts.char.toLocaleString('zh-CN')} 题 + 成语 ${counts.idiom.toLocaleString('zh-CN')} 题`,
|
|
17
|
+
commandPrefix: '猜谜',
|
|
18
|
+
quickStart: '开始',
|
|
19
|
+
aliases: ['riddle'],
|
|
20
|
+
menus: [
|
|
21
|
+
{ id: 'char', label: '🔤 字谜模式', style: 'primary' },
|
|
22
|
+
{ id: 'idiom', label: '📜 成语模式' },
|
|
23
|
+
{ id: 'continue', label: '🔄 继续' },
|
|
24
|
+
{ id: 'help', label: '📖 玩法说明' },
|
|
25
|
+
],
|
|
26
|
+
runAction: async (actionId, ctx) => {
|
|
27
|
+
const services = getServices();
|
|
28
|
+
if (!services) return '猜谜需要启用 database 配置。';
|
|
29
|
+
return runRiddleCommand(ctx.plugin, services, ctx.message, actionId === 'start' ? 'char' : actionId);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { RIDDLE_HELP };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Cron, formatCompact, usePlugin, type DatabaseFeature } from 'zhin.js';
|
|
2
|
+
import { registerModels } from './models.js';
|
|
3
|
+
import { createServices, resolveGameDatabase, type SessionService } from './session-service.js';
|
|
4
|
+
import { registerCommands, registerInteractive, registerTextMiddleware } from './commands.js';
|
|
5
|
+
import { registerRiddleHub } from './hub-register.js';
|
|
6
|
+
|
|
7
|
+
const plugin = usePlugin();
|
|
8
|
+
const { logger, useContext, addCron } = plugin;
|
|
9
|
+
|
|
10
|
+
registerModels(plugin);
|
|
11
|
+
|
|
12
|
+
let services: SessionService | null = null;
|
|
13
|
+
|
|
14
|
+
useContext('database', (dbFeature: DatabaseFeature) => {
|
|
15
|
+
services = createServices(resolveGameDatabase(dbFeature));
|
|
16
|
+
logger.info(formatCompact({ 模块: '猜谜', 数据模型: '已就绪' }));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
registerRiddleHub(() => services);
|
|
20
|
+
registerCommands(plugin, () => services);
|
|
21
|
+
registerInteractive(plugin, () => services);
|
|
22
|
+
registerTextMiddleware(plugin, () => services);
|
|
23
|
+
|
|
24
|
+
addCron(
|
|
25
|
+
new Cron('0 */15 * * * *', async () => {
|
|
26
|
+
if (!services) return;
|
|
27
|
+
const n = await services.abortStale(45 * 60 * 1000);
|
|
28
|
+
if (n > 0) logger.debug(formatCompact({ 猜谜: '清理超时局', count: n }));
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
logger.info(formatCompact({ 模块: '猜谜', 状态: '已加载' }));
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Models, Plugin } from 'zhin.js';
|
|
2
|
+
|
|
3
|
+
export type RiddleSessionStatus = 'active' | 'completed' | 'aborted';
|
|
4
|
+
|
|
5
|
+
declare module 'zhin.js' {
|
|
6
|
+
interface Models {
|
|
7
|
+
word_riddle_sessions: {
|
|
8
|
+
id: string;
|
|
9
|
+
adapter: string;
|
|
10
|
+
endpoint: string;
|
|
11
|
+
channel_type: string;
|
|
12
|
+
channel_id: string;
|
|
13
|
+
channel_key: string;
|
|
14
|
+
player_id: string;
|
|
15
|
+
player_name: string;
|
|
16
|
+
mode: string;
|
|
17
|
+
queue: string;
|
|
18
|
+
index: number;
|
|
19
|
+
score: number;
|
|
20
|
+
streak: number;
|
|
21
|
+
best_streak: number;
|
|
22
|
+
hints_used: number;
|
|
23
|
+
wrong_count: number;
|
|
24
|
+
status: RiddleSessionStatus;
|
|
25
|
+
board_message_id: string;
|
|
26
|
+
updated_at: number;
|
|
27
|
+
created_at: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type RiddleSessionRow = Models['word_riddle_sessions'];
|
|
33
|
+
|
|
34
|
+
export function registerModels(plugin: Plugin): void {
|
|
35
|
+
plugin.defineModel('word_riddle_sessions', {
|
|
36
|
+
id: { type: 'text', primary: true },
|
|
37
|
+
adapter: { type: 'text', nullable: false },
|
|
38
|
+
endpoint: { type: 'text', nullable: false },
|
|
39
|
+
channel_type: { type: 'text', nullable: false },
|
|
40
|
+
channel_id: { type: 'text', nullable: false },
|
|
41
|
+
channel_key: { type: 'text', nullable: false },
|
|
42
|
+
player_id: { type: 'text', nullable: false },
|
|
43
|
+
player_name: { type: 'text', default: '' },
|
|
44
|
+
mode: { type: 'text', default: 'char' },
|
|
45
|
+
queue: { type: 'text', default: '[]' },
|
|
46
|
+
index: { type: 'integer', default: 0 },
|
|
47
|
+
score: { type: 'integer', default: 0 },
|
|
48
|
+
streak: { type: 'integer', default: 0 },
|
|
49
|
+
best_streak: { type: 'integer', default: 0 },
|
|
50
|
+
hints_used: { type: 'integer', default: 0 },
|
|
51
|
+
wrong_count: { type: 'integer', default: 0 },
|
|
52
|
+
status: { type: 'text', default: 'active' },
|
|
53
|
+
board_message_id: { type: 'text', default: '' },
|
|
54
|
+
updated_at: { type: 'integer', default: 0 },
|
|
55
|
+
created_at: { type: 'integer', default: 0 },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Message, Plugin } from 'zhin.js';
|
|
2
|
+
import { channelKey } from '@zhin.js/game-shared';
|
|
3
|
+
import { continueGame, startGame } from './game-flow.js';
|
|
4
|
+
import { riddleCount } from './data/riddles.js';
|
|
5
|
+
import type { RiddleType } from './data/riddles.js';
|
|
6
|
+
import { typeLabel } from './engine.js';
|
|
7
|
+
import type { SessionService } from './session-service.js';
|
|
8
|
+
|
|
9
|
+
const counts = riddleCount();
|
|
10
|
+
|
|
11
|
+
export const RIDDLE_HELP = [
|
|
12
|
+
'🧩 猜谜(字谜 + 猜成语)',
|
|
13
|
+
`字谜 ${counts.char.toLocaleString('zh-CN')} 题 · 成语 ${counts.idiom.toLocaleString('zh-CN')} 题`,
|
|
14
|
+
'猜谜 / riddle — 帮助',
|
|
15
|
+
'猜谜 开始 — 字谜模式',
|
|
16
|
+
'猜谜 字谜 — 字谜模式',
|
|
17
|
+
'猜谜 成语 — 猜成语模式',
|
|
18
|
+
'猜谜 继续 — 刷新界面',
|
|
19
|
+
'猜谜 放弃 — 结束',
|
|
20
|
+
'',
|
|
21
|
+
'进行中直接回复答案;连击加分,提示/失误会清零连击。',
|
|
22
|
+
].join('\n');
|
|
23
|
+
|
|
24
|
+
function parseMode(action: string): RiddleType | null {
|
|
25
|
+
if (action === 'char' || action === '字谜') return 'char';
|
|
26
|
+
if (action === 'idiom' || action === '成语') return 'idiom';
|
|
27
|
+
if (action === 'start' || action === '开始') return 'char';
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runRiddleCommand(
|
|
32
|
+
plugin: Plugin,
|
|
33
|
+
services: SessionService,
|
|
34
|
+
message: Message<any>,
|
|
35
|
+
action: string,
|
|
36
|
+
): Promise<string | undefined> {
|
|
37
|
+
const ch = channelKey(message);
|
|
38
|
+
const userId = message.$sender.id;
|
|
39
|
+
|
|
40
|
+
if (!action || action === 'help') {
|
|
41
|
+
const active = await services.getActiveByChannel(ch);
|
|
42
|
+
const lines = [RIDDLE_HELP, ''];
|
|
43
|
+
if (active) {
|
|
44
|
+
lines.push(`进行中:${typeLabel(active.mode as RiddleType)} · 得分 ${active.score} · 连击 ${active.streak}`);
|
|
45
|
+
} else {
|
|
46
|
+
lines.push('暂无对局,发送「猜谜 开始」。');
|
|
47
|
+
}
|
|
48
|
+
return lines.join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const mode = parseMode(action);
|
|
52
|
+
if (mode) return startGame(plugin, services, message, mode);
|
|
53
|
+
|
|
54
|
+
if (action === 'continue' || action === '继续') {
|
|
55
|
+
return continueGame(plugin, services, message);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (action === 'quit' || action === '放弃') {
|
|
59
|
+
const row = await services.getActiveForUser(ch, userId);
|
|
60
|
+
if (!row) return '你没有进行中的猜谜。';
|
|
61
|
+
await services.updateSession(row.id, { status: 'aborted' });
|
|
62
|
+
return '已放弃猜谜。';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return `未知子命令:${action}\n\n${RIDDLE_HELP}`;
|
|
66
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 猜谜词库
|
|
3
|
+
* - 字谜:riddle_demo CSV(构建为 char-riddles.json)
|
|
4
|
+
* - 成语:npm `chinese-idiom-chengyu`(MIT)
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
|
|
11
|
+
export type RiddleType = 'char' | 'idiom';
|
|
12
|
+
|
|
13
|
+
export interface RiddleEntry {
|
|
14
|
+
id: string;
|
|
15
|
+
type: RiddleType;
|
|
16
|
+
question: string;
|
|
17
|
+
answer: string;
|
|
18
|
+
aliases?: string[];
|
|
19
|
+
hint?: string;
|
|
20
|
+
explanation?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
|
|
26
|
+
const charRiddles = JSON.parse(
|
|
27
|
+
readFileSync(join(__dirname, 'data/char-riddles.json'), 'utf8'),
|
|
28
|
+
) as RiddleEntry[];
|
|
29
|
+
|
|
30
|
+
interface ChengyuLib {
|
|
31
|
+
getDefinition: (word: string) => string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const chengyu = require('chinese-idiom-chengyu') as ChengyuLib;
|
|
35
|
+
const { WORD_PINYIN_MAP } = require('chinese-idiom-chengyu/src/tools/parse.js') as {
|
|
36
|
+
WORD_PINYIN_MAP: Map<string, string>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const FOUR_CHAR = /^[\u4e00-\u9fff]{4}$/;
|
|
40
|
+
|
|
41
|
+
const charPool: RiddleEntry[] = charRiddles as RiddleEntry[];
|
|
42
|
+
const charById = new Map(charPool.map((r) => [r.id, r]));
|
|
43
|
+
|
|
44
|
+
let idiomPool: RiddleEntry[] | null = null;
|
|
45
|
+
|
|
46
|
+
function buildIdiomEntry(word: string): RiddleEntry | null {
|
|
47
|
+
let gloss: string;
|
|
48
|
+
try {
|
|
49
|
+
gloss = chengyu.getDefinition(word).trim();
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (!gloss || gloss.length < 4 || gloss === word) return null;
|
|
54
|
+
return {
|
|
55
|
+
id: `i:${word}`,
|
|
56
|
+
type: 'idiom',
|
|
57
|
+
question: `${gloss}。(猜成语)`,
|
|
58
|
+
answer: word,
|
|
59
|
+
hint: '共 4 个字',
|
|
60
|
+
explanation: gloss,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildIdiomPool(): RiddleEntry[] {
|
|
65
|
+
if (idiomPool) return idiomPool;
|
|
66
|
+
const pool: RiddleEntry[] = [];
|
|
67
|
+
for (const word of WORD_PINYIN_MAP.keys()) {
|
|
68
|
+
if (!FOUR_CHAR.test(word)) continue;
|
|
69
|
+
const entry = buildIdiomEntry(word);
|
|
70
|
+
if (entry) pool.push(entry);
|
|
71
|
+
}
|
|
72
|
+
idiomPool = pool;
|
|
73
|
+
return pool;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function riddlesByType(type: RiddleType): RiddleEntry[] {
|
|
77
|
+
return type === 'char' ? charPool : buildIdiomPool();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getRiddleById(id: string): RiddleEntry | undefined {
|
|
81
|
+
if (id.startsWith('c:')) return charById.get(id);
|
|
82
|
+
if (id.startsWith('i:')) return buildIdiomEntry(id.slice(2)) ?? undefined;
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function riddleCount(): { char: number; idiom: number; total: number } {
|
|
87
|
+
const char = charPool.length;
|
|
88
|
+
const idiom = buildIdiomPool().length;
|
|
89
|
+
return { char, idiom, total: char + idiom };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 每局随机抽题数量(避免把整库写入 session queue) */
|
|
93
|
+
export const QUESTIONS_PER_ROUND = 10;
|
|
94
|
+
|
|
95
|
+
export function pickRoundQueue(type: RiddleType, count = QUESTIONS_PER_ROUND): RiddleEntry[] {
|
|
96
|
+
const pool = riddlesByType(type);
|
|
97
|
+
const n = pool.length;
|
|
98
|
+
const k = Math.min(count, n);
|
|
99
|
+
const indices = Array.from({ length: n }, (_, i) => i);
|
|
100
|
+
for (let i = 0; i < k; i++) {
|
|
101
|
+
const j = i + Math.floor(Math.random() * (n - i));
|
|
102
|
+
[indices[i], indices[j]] = [indices[j]!, indices[i]!];
|
|
103
|
+
}
|
|
104
|
+
return indices.slice(0, k).map((i) => pool[i]!);
|
|
105
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Database, DatabaseFeature, Message, Models, RelatedModel } from 'zhin.js';
|
|
2
|
+
import { channelKey, generateSessionId } from '@zhin.js/game-shared';
|
|
3
|
+
import { pickRoundQueue, type RiddleType } from './data/riddles.js';
|
|
4
|
+
import type { RiddleSessionRow } from './models.js';
|
|
5
|
+
|
|
6
|
+
export type RiddleDatabase = Database<unknown, Models, string>;
|
|
7
|
+
|
|
8
|
+
function getModel(db: RiddleDatabase) {
|
|
9
|
+
const model = db.models.get('word_riddle_sessions');
|
|
10
|
+
if (!model) throw new Error('word_riddle_sessions not registered');
|
|
11
|
+
return model as RelatedModel<unknown, Models, 'word_riddle_sessions'>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseQueue(value: string | string[] | unknown): string[] {
|
|
15
|
+
if (Array.isArray(value)) return value.filter((x): x is string => typeof x === 'string');
|
|
16
|
+
if (typeof value !== 'string' || !value) return [];
|
|
17
|
+
try {
|
|
18
|
+
const v = JSON.parse(value);
|
|
19
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class SessionService {
|
|
26
|
+
constructor(private readonly db: RiddleDatabase) {}
|
|
27
|
+
|
|
28
|
+
async getActiveByChannel(channel: string): Promise<RiddleSessionRow | null> {
|
|
29
|
+
const rows = await getModel(this.db).findAll({ channel_key: channel, status: 'active' });
|
|
30
|
+
return rows[0] ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async getActiveForUser(channel: string, userId: string): Promise<RiddleSessionRow | null> {
|
|
34
|
+
const row = await this.getActiveByChannel(channel);
|
|
35
|
+
if (!row || row.player_id !== userId) return null;
|
|
36
|
+
return row;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async getById(id: string): Promise<RiddleSessionRow | null> {
|
|
40
|
+
return getModel(this.db).findOne({ id });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getActiveByBoardMessageId(messageId: string): Promise<RiddleSessionRow | null> {
|
|
44
|
+
if (!messageId) return null;
|
|
45
|
+
const rows = await getModel(this.db).findAll({ status: 'active' });
|
|
46
|
+
for (const row of rows) {
|
|
47
|
+
const stored = row.board_message_id;
|
|
48
|
+
if (!stored) continue;
|
|
49
|
+
if (stored === messageId || stored.endsWith(`:${messageId}`)) return row;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async createSession(message: Message<any>, mode: RiddleType): Promise<RiddleSessionRow> {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const queue = pickRoundQueue(mode).map((r) => r.id);
|
|
57
|
+
const row: RiddleSessionRow = {
|
|
58
|
+
id: generateSessionId(),
|
|
59
|
+
adapter: String(message.$adapter),
|
|
60
|
+
endpoint: message.$endpoint,
|
|
61
|
+
channel_type: message.$channel.type,
|
|
62
|
+
channel_id: message.$channel.id,
|
|
63
|
+
channel_key: channelKey(message),
|
|
64
|
+
player_id: message.$sender.id,
|
|
65
|
+
player_name: message.$sender.name?.trim() || message.$sender.id,
|
|
66
|
+
mode,
|
|
67
|
+
queue: JSON.stringify(queue),
|
|
68
|
+
index: 0,
|
|
69
|
+
score: 0,
|
|
70
|
+
streak: 0,
|
|
71
|
+
best_streak: 0,
|
|
72
|
+
hints_used: 0,
|
|
73
|
+
wrong_count: 0,
|
|
74
|
+
status: 'active',
|
|
75
|
+
board_message_id: '',
|
|
76
|
+
updated_at: now,
|
|
77
|
+
created_at: now,
|
|
78
|
+
};
|
|
79
|
+
await getModel(this.db).create(row);
|
|
80
|
+
return row;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async updateSession(id: string, patch: Partial<RiddleSessionRow>): Promise<void> {
|
|
84
|
+
await getModel(this.db).updateWhere({ id }, { ...patch, updated_at: Date.now() });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async abortStale(idleMs: number): Promise<number> {
|
|
88
|
+
const cutoff = Date.now() - idleMs;
|
|
89
|
+
const model = getModel(this.db);
|
|
90
|
+
const rows = await model.findAll({ status: 'active' });
|
|
91
|
+
let n = 0;
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
if (row.updated_at < cutoff) {
|
|
94
|
+
await model.updateWhere({ id: row.id }, { status: 'aborted', updated_at: Date.now() });
|
|
95
|
+
n++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return n;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createServices(db: RiddleDatabase): SessionService {
|
|
103
|
+
return new SessionService(db);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolveGameDatabase(feature: DatabaseFeature): RiddleDatabase {
|
|
107
|
+
return feature.db;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function currentRiddleId(session: RiddleSessionRow): string | null {
|
|
111
|
+
const queue = parseQueue(session.queue);
|
|
112
|
+
return queue[session.index] ?? null;
|
|
113
|
+
}
|