@zhin.js/plugin-idiom-chain 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/lib/chain-command.d.ts +5 -0
- package/lib/chain-command.d.ts.map +1 -0
- package/lib/chain-command.js +48 -0
- package/lib/chain-command.js.map +1 -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/idioms.d.ts +8 -0
- package/lib/data/idioms.d.ts.map +1 -0
- package/lib/data/idioms.js +422 -0
- package/lib/data/idioms.js.map +1 -0
- package/lib/engine.d.ts +4 -0
- package/lib/engine.d.ts.map +1 -0
- package/lib/engine.js +3 -0
- package/lib/engine.js.map +1 -0
- package/lib/game-flow.d.ts +11 -0
- package/lib/game-flow.d.ts.map +1 -0
- package/lib/game-flow.js +203 -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 +31 -0
- package/lib/hub-register.js.map +1 -0
- package/lib/idiom-provider.d.ts +23 -0
- package/lib/idiom-provider.d.ts.map +1 -0
- package/lib/idiom-provider.js +110 -0
- package/lib/idiom-provider.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 +35 -0
- package/lib/models.d.ts.map +1 -0
- package/lib/models.js +28 -0
- package/lib/models.js.map +1 -0
- package/lib/session-service.d.ts +25 -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 +54 -0
- package/lib/view.js.map +1 -0
- package/package.json +38 -0
- package/plugin.yml +2 -0
- package/src/chain-command.ts +57 -0
- package/src/commands.ts +112 -0
- package/src/engine.ts +18 -0
- package/src/game-flow.ts +270 -0
- package/src/hub-register.ts +32 -0
- package/src/idiom-provider.ts +165 -0
- package/src/index.ts +32 -0
- package/src/models.ts +64 -0
- package/src/session-service.ts +117 -0
- package/src/view.ts +65 -0
package/src/game-flow.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { Adapter, Message, Plugin } from 'zhin.js';
|
|
2
|
+
import {
|
|
3
|
+
CHAIN_PREFIX,
|
|
4
|
+
getGloss,
|
|
5
|
+
lastChar,
|
|
6
|
+
modeLabel,
|
|
7
|
+
normalizeInput,
|
|
8
|
+
pickBotIdiom,
|
|
9
|
+
pickHintIdiom,
|
|
10
|
+
pickStarterIdiom,
|
|
11
|
+
promptLine,
|
|
12
|
+
validatePlayerIdiom,
|
|
13
|
+
type MatchMode,
|
|
14
|
+
} from './engine.js';
|
|
15
|
+
import type { ChainSessionRow } from './models.js';
|
|
16
|
+
import { parseUsed, type SessionService } from './session-service.js';
|
|
17
|
+
import { buildChainView, MAX_WRONG } from './view.js';
|
|
18
|
+
|
|
19
|
+
function sessionMode(session: ChainSessionRow): MatchMode {
|
|
20
|
+
return session.match_mode === 'char' ? 'char' : 'pinyin';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function sendOrEditView(
|
|
24
|
+
plugin: Plugin,
|
|
25
|
+
services: SessionService,
|
|
26
|
+
message: Message<any>,
|
|
27
|
+
session: ChainSessionRow,
|
|
28
|
+
eventLines: string[] = [],
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const content = buildChainView(session, eventLines);
|
|
31
|
+
const adapter = plugin.root.inject(message.$adapter) as Adapter;
|
|
32
|
+
|
|
33
|
+
if (session.board_message_id) {
|
|
34
|
+
const msgId = await adapter.editMessage({
|
|
35
|
+
messageId: session.board_message_id,
|
|
36
|
+
context: String(message.$adapter),
|
|
37
|
+
endpoint: message.$endpoint,
|
|
38
|
+
id: message.$channel.id,
|
|
39
|
+
type: message.$channel.type,
|
|
40
|
+
content,
|
|
41
|
+
});
|
|
42
|
+
if (msgId !== session.board_message_id) {
|
|
43
|
+
await services.updateSession(session.id, { board_message_id: msgId });
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const msgId = await message.$reply?.(content);
|
|
49
|
+
if (msgId) await services.updateSession(session.id, { board_message_id: msgId });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function botTurn(
|
|
53
|
+
services: SessionService,
|
|
54
|
+
session: ChainSessionRow,
|
|
55
|
+
used: Set<string>,
|
|
56
|
+
): Promise<{ session: ChainSessionRow; lines: string[]; playerWon: boolean }> {
|
|
57
|
+
const mode = sessionMode(session);
|
|
58
|
+
const bot = pickBotIdiom(session.last_idiom, mode, used);
|
|
59
|
+
if (!bot) {
|
|
60
|
+
await services.updateSession(session.id, {
|
|
61
|
+
status: 'won',
|
|
62
|
+
player_score: session.player_score + 1,
|
|
63
|
+
});
|
|
64
|
+
const updated = (await services.getById(session.id))!;
|
|
65
|
+
return { session: updated, lines: ['🎉 机器人接不上,你赢本局!'], playerWon: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
used.add(bot.text);
|
|
69
|
+
const next = lastChar(bot.text);
|
|
70
|
+
const gloss = bot.gloss ?? getGloss(bot.text);
|
|
71
|
+
await services.updateSession(session.id, {
|
|
72
|
+
last_idiom: bot.text,
|
|
73
|
+
next_char: next,
|
|
74
|
+
used_idioms: JSON.stringify([...used]),
|
|
75
|
+
turn: 'player',
|
|
76
|
+
});
|
|
77
|
+
const updated = (await services.getById(session.id))!;
|
|
78
|
+
const lines = [
|
|
79
|
+
`🤖 机器人:${bot.text}${gloss ? `(${gloss})` : ''}`,
|
|
80
|
+
promptLine(bot.text, mode),
|
|
81
|
+
];
|
|
82
|
+
return { session: updated, lines, playerWon: false };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function startGame(
|
|
86
|
+
plugin: Plugin,
|
|
87
|
+
services: SessionService,
|
|
88
|
+
message: Message<any>,
|
|
89
|
+
matchMode: MatchMode = 'pinyin',
|
|
90
|
+
): Promise<string | undefined> {
|
|
91
|
+
const ch = `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
|
|
92
|
+
const active = await services.getActiveByChannel(ch);
|
|
93
|
+
if (active) {
|
|
94
|
+
if (active.player_id === message.$sender.id) {
|
|
95
|
+
return '你已有进行中的接龙,发送「接龙 继续」刷新。';
|
|
96
|
+
}
|
|
97
|
+
return `本频道 ${active.player_name} 正在成语接龙。`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const used = new Set<string>();
|
|
101
|
+
const starter = pickStarterIdiom(used);
|
|
102
|
+
used.add(starter.text);
|
|
103
|
+
const session = await services.createSession(message, {
|
|
104
|
+
text: starter.text,
|
|
105
|
+
nextChar: lastChar(starter.text),
|
|
106
|
+
used: [...used],
|
|
107
|
+
matchMode,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const gloss = starter.gloss ?? getGloss(starter.text);
|
|
111
|
+
await sendOrEditView(plugin, services, message, session, [
|
|
112
|
+
`🎬 ${modeLabel(matchMode)}开局!我先出:**${starter.text}**${gloss ? `(${gloss})` : ''}`,
|
|
113
|
+
promptLine(starter.text, matchMode),
|
|
114
|
+
]);
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function continueGame(
|
|
119
|
+
plugin: Plugin,
|
|
120
|
+
services: SessionService,
|
|
121
|
+
message: Message<any>,
|
|
122
|
+
): Promise<string> {
|
|
123
|
+
const session = await services.getActiveForUser(
|
|
124
|
+
`${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`,
|
|
125
|
+
message.$sender.id,
|
|
126
|
+
);
|
|
127
|
+
if (!session) return '你没有进行中的接龙,发送「接龙 开始」。';
|
|
128
|
+
await sendOrEditView(plugin, services, message, session);
|
|
129
|
+
return '已刷新接龙界面。';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function processIdiomText(
|
|
133
|
+
plugin: Plugin,
|
|
134
|
+
services: SessionService,
|
|
135
|
+
message: Message<any>,
|
|
136
|
+
raw: string,
|
|
137
|
+
): Promise<string | null> {
|
|
138
|
+
const ch = `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
|
|
139
|
+
const session = await services.getActiveForUser(ch, message.$sender.id);
|
|
140
|
+
if (!session || session.status !== 'active') return null;
|
|
141
|
+
|
|
142
|
+
const mode = sessionMode(session);
|
|
143
|
+
const used = parseUsed(session.used_idioms);
|
|
144
|
+
const idiom = normalizeInput(raw);
|
|
145
|
+
const check = validatePlayerIdiom(idiom, session.last_idiom, mode, used);
|
|
146
|
+
if (!check.ok) {
|
|
147
|
+
const wrong = session.wrong_count + 1;
|
|
148
|
+
if (wrong >= MAX_WRONG) {
|
|
149
|
+
await services.updateSession(session.id, {
|
|
150
|
+
wrong_count: wrong,
|
|
151
|
+
status: 'lost',
|
|
152
|
+
bot_score: session.bot_score + 1,
|
|
153
|
+
streak: 0,
|
|
154
|
+
});
|
|
155
|
+
const updated = (await services.getById(session.id))!;
|
|
156
|
+
await sendOrEditView(plugin, services, message, updated, [check.reason!, '失误过多,本局结束。']);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
await services.updateSession(session.id, { wrong_count: wrong, streak: 0 });
|
|
160
|
+
const updated = (await services.getById(session.id))!;
|
|
161
|
+
await sendOrEditView(plugin, services, message, updated, [check.reason!]);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
used.add(idiom);
|
|
166
|
+
const streak = session.streak + 1;
|
|
167
|
+
const best = Math.max(session.best_streak, streak);
|
|
168
|
+
const nextChar = lastChar(idiom);
|
|
169
|
+
const gloss = getGloss(idiom);
|
|
170
|
+
|
|
171
|
+
await services.updateSession(session.id, {
|
|
172
|
+
last_idiom: idiom,
|
|
173
|
+
next_char: nextChar,
|
|
174
|
+
used_idioms: JSON.stringify([...used]),
|
|
175
|
+
streak,
|
|
176
|
+
best_streak: best,
|
|
177
|
+
wrong_count: 0,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const current = (await services.getById(session.id))!;
|
|
181
|
+
const userLine = `✅ 你:${idiom}${gloss ? `(${gloss})` : ''}`;
|
|
182
|
+
|
|
183
|
+
const botResult = await botTurn(services, current, used);
|
|
184
|
+
await sendOrEditView(plugin, services, message, botResult.session, [userLine, ...botResult.lines]);
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function handleChoice(
|
|
189
|
+
plugin: Plugin,
|
|
190
|
+
services: SessionService,
|
|
191
|
+
message: Message<any>,
|
|
192
|
+
sessionId: string,
|
|
193
|
+
choiceId: string,
|
|
194
|
+
): Promise<string | null> {
|
|
195
|
+
const session = await services.getById(sessionId);
|
|
196
|
+
if (!session) return '对局不存在。';
|
|
197
|
+
if (session.player_id !== message.$sender.id) return '这是别人的接龙。';
|
|
198
|
+
|
|
199
|
+
const mode = sessionMode(session);
|
|
200
|
+
|
|
201
|
+
if (choiceId === 'restart') {
|
|
202
|
+
const used = new Set<string>();
|
|
203
|
+
const starter = pickStarterIdiom(used);
|
|
204
|
+
used.add(starter.text);
|
|
205
|
+
await services.updateSession(session.id, {
|
|
206
|
+
last_idiom: starter.text,
|
|
207
|
+
next_char: lastChar(starter.text),
|
|
208
|
+
used_idioms: JSON.stringify([...used]),
|
|
209
|
+
streak: 0,
|
|
210
|
+
wrong_count: 0,
|
|
211
|
+
turn: 'player',
|
|
212
|
+
status: 'active',
|
|
213
|
+
board_message_id: '',
|
|
214
|
+
});
|
|
215
|
+
const updated = (await services.getById(session.id))!;
|
|
216
|
+
const gloss = starter.gloss ?? getGloss(starter.text);
|
|
217
|
+
await sendOrEditView(plugin, services, message, updated, [
|
|
218
|
+
`🎬 新一局!我先出:**${starter.text}**${gloss ? `(${gloss})` : ''}`,
|
|
219
|
+
promptLine(starter.text, mode),
|
|
220
|
+
]);
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (session.status !== 'active') {
|
|
225
|
+
return '本局已结束,请点击再来一局。';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const used = parseUsed(session.used_idioms);
|
|
229
|
+
|
|
230
|
+
if (choiceId === 'hint') {
|
|
231
|
+
const hint = pickHintIdiom(session.last_idiom, mode, used);
|
|
232
|
+
if (!hint) {
|
|
233
|
+
await sendOrEditView(plugin, services, message, session, ['💡 词库中暂无可用提示,你赢了!']);
|
|
234
|
+
await services.updateSession(session.id, { status: 'won', player_score: session.player_score + 1 });
|
|
235
|
+
const updated = (await services.getById(session.id))!;
|
|
236
|
+
await sendOrEditView(plugin, services, message, updated);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
const gloss = getGloss(hint);
|
|
240
|
+
await services.updateSession(session.id, { hints_used: session.hints_used + 1, streak: 0 });
|
|
241
|
+
const updated = (await services.getById(session.id))!;
|
|
242
|
+
await sendOrEditView(plugin, services, message, updated, [
|
|
243
|
+
`💡 提示:可试 **${hint}**${gloss ? `(${gloss})` : ''}`,
|
|
244
|
+
'(使用提示会清零连击)',
|
|
245
|
+
]);
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (choiceId === 'skip') {
|
|
250
|
+
await services.updateSession(session.id, {
|
|
251
|
+
status: 'lost',
|
|
252
|
+
bot_score: session.bot_score + 1,
|
|
253
|
+
streak: 0,
|
|
254
|
+
});
|
|
255
|
+
const updated = (await services.getById(session.id))!;
|
|
256
|
+
await sendOrEditView(plugin, services, message, updated, ['⏭️ 你选择跳过,机器人得一分。']);
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (choiceId === 'quit') {
|
|
261
|
+
await services.updateSession(session.id, { status: 'lost', bot_score: session.bot_score + 1 });
|
|
262
|
+
const updated = (await services.getById(session.id))!;
|
|
263
|
+
await sendOrEditView(plugin, services, message, updated, ['🏳️ 你认输了。']);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return '未知操作。';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export { CHAIN_PREFIX };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getPlugin } from 'zhin.js';
|
|
2
|
+
import { ensureGameHubService } from '@zhin.js/game-shared';
|
|
3
|
+
import { idiomCount } from './engine.js';
|
|
4
|
+
import { runChainCommand, CHAIN_HELP } from './chain-command.js';
|
|
5
|
+
import type { SessionService } from './session-service.js';
|
|
6
|
+
|
|
7
|
+
export function registerChainHub(getServices: () => SessionService | null): () => void {
|
|
8
|
+
const plugin = getPlugin();
|
|
9
|
+
ensureGameHubService(plugin);
|
|
10
|
+
return plugin.registerGame({
|
|
11
|
+
id: 'chain',
|
|
12
|
+
title: '成语接龙',
|
|
13
|
+
icon: '📜',
|
|
14
|
+
description: `四字成语接龙(同音/同字),词库 ${idiomCount()} 条`,
|
|
15
|
+
commandPrefix: '接龙',
|
|
16
|
+
quickStart: 'start_pinyin',
|
|
17
|
+
aliases: ['chain'],
|
|
18
|
+
menus: [
|
|
19
|
+
{ id: 'start_pinyin', label: '🎮 同音接龙', style: 'primary' },
|
|
20
|
+
{ id: 'start_char', label: '📝 同字接龙' },
|
|
21
|
+
{ id: 'continue', label: '🔄 继续' },
|
|
22
|
+
{ id: 'help', label: '📖 玩法说明' },
|
|
23
|
+
],
|
|
24
|
+
runAction: async (actionId, ctx) => {
|
|
25
|
+
const services = getServices();
|
|
26
|
+
if (!services) return '成语接龙需要启用 database 配置。';
|
|
27
|
+
return runChainCommand(ctx.plugin, services, ctx.message, actionId);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { CHAIN_HELP };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 成语词库适配层 — 基于 npm `chinese-idiom-chengyu`(MIT,~3 万成语)
|
|
3
|
+
* @see https://www.npmjs.com/package/chinese-idiom-chengyu
|
|
4
|
+
*/
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
export type MatchMode = 'char' | 'pinyin';
|
|
10
|
+
|
|
11
|
+
export interface IdiomEntry {
|
|
12
|
+
text: string;
|
|
13
|
+
gloss?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ChengyuLib {
|
|
17
|
+
nextIdiomsWithMatchingCharacter: (word: string, opts: { word: boolean; pinyin: boolean }) => string[];
|
|
18
|
+
nextIdiomsWithMatchingNoTonePinyin: (word: string, opts: { word: boolean; pinyin: boolean }) => string[];
|
|
19
|
+
getDefinition: (word: string) => string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const chengyu = require('chinese-idiom-chengyu') as ChengyuLib;
|
|
23
|
+
const { WORD_PINYIN_MAP } = require('chinese-idiom-chengyu/src/tools/parse.js') as {
|
|
24
|
+
WORD_PINYIN_MAP: Map<string, string>;
|
|
25
|
+
};
|
|
26
|
+
const Util = require('chinese-idiom-chengyu/src/tools/util.js') as {
|
|
27
|
+
pinyinToLetters: (pinyin: string) => string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const FOUR_CHAR = /^[\u4e00-\u9fff]{4}$/;
|
|
31
|
+
const SEARCH_OPTS = { word: true, pinyin: false } as const;
|
|
32
|
+
|
|
33
|
+
let fourCharPool: string[] | null = null;
|
|
34
|
+
|
|
35
|
+
function buildFourCharPool(): string[] {
|
|
36
|
+
if (fourCharPool) return fourCharPool;
|
|
37
|
+
const pool: string[] = [];
|
|
38
|
+
for (const word of WORD_PINYIN_MAP.keys()) {
|
|
39
|
+
if (FOUR_CHAR.test(word)) pool.push(word);
|
|
40
|
+
}
|
|
41
|
+
fourCharPool = pool;
|
|
42
|
+
return pool;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function idiomCount(): number {
|
|
46
|
+
return buildFourCharPool().length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeInput(raw: string): string {
|
|
50
|
+
return raw.trim().replace(/\s/g, '');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isKnownIdiom(text: string): boolean {
|
|
54
|
+
return WORD_PINYIN_MAP.has(text);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getGloss(text: string): string | undefined {
|
|
58
|
+
try {
|
|
59
|
+
return chengyu.getDefinition(text);
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function lastChar(idiom: string): string {
|
|
66
|
+
return idiom[idiom.length - 1]!;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function lastSyllableNoTone(idiom: string): string {
|
|
70
|
+
const py = WORD_PINYIN_MAP.get(idiom);
|
|
71
|
+
if (!py) return '';
|
|
72
|
+
const tail = py.split(' ').pop() ?? '';
|
|
73
|
+
return Util.pinyinToLetters(tail);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function modeLabel(mode: MatchMode): string {
|
|
77
|
+
return mode === 'char' ? '同字接龙' : '同音接龙';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getValidNext(
|
|
81
|
+
prevIdiom: string,
|
|
82
|
+
mode: MatchMode,
|
|
83
|
+
used: Set<string>,
|
|
84
|
+
): IdiomEntry[] {
|
|
85
|
+
if (!WORD_PINYIN_MAP.has(prevIdiom)) return [];
|
|
86
|
+
|
|
87
|
+
const raw =
|
|
88
|
+
mode === 'char'
|
|
89
|
+
? chengyu.nextIdiomsWithMatchingCharacter(prevIdiom, SEARCH_OPTS)
|
|
90
|
+
: chengyu.nextIdiomsWithMatchingNoTonePinyin(prevIdiom, SEARCH_OPTS);
|
|
91
|
+
|
|
92
|
+
return raw
|
|
93
|
+
.filter((w) => FOUR_CHAR.test(w) && !used.has(w))
|
|
94
|
+
.map((text) => ({ text, gloss: getGloss(text) }));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function pickBotIdiom(
|
|
98
|
+
prevIdiom: string,
|
|
99
|
+
mode: MatchMode,
|
|
100
|
+
used: Set<string>,
|
|
101
|
+
): IdiomEntry | null {
|
|
102
|
+
const candidates = getValidNext(prevIdiom, mode, used);
|
|
103
|
+
if (!candidates.length) return null;
|
|
104
|
+
return candidates[Math.floor(Math.random() * candidates.length)]!;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function pickStarterIdiom(used: Set<string>): IdiomEntry {
|
|
108
|
+
const pool = buildFourCharPool().filter((w) => !used.has(w));
|
|
109
|
+
const text = pool[Math.floor(Math.random() * pool.length)] ?? '一心一意';
|
|
110
|
+
return { text, gloss: getGloss(text) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function pickHintIdiom(
|
|
114
|
+
prevIdiom: string,
|
|
115
|
+
mode: MatchMode,
|
|
116
|
+
used: Set<string>,
|
|
117
|
+
): string | null {
|
|
118
|
+
return pickBotIdiom(prevIdiom, mode, used)?.text ?? null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface ValidateResult {
|
|
122
|
+
ok: boolean;
|
|
123
|
+
reason?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function validatePlayerIdiom(
|
|
127
|
+
text: string,
|
|
128
|
+
prevIdiom: string,
|
|
129
|
+
mode: MatchMode,
|
|
130
|
+
used: Set<string>,
|
|
131
|
+
): ValidateResult {
|
|
132
|
+
const idiom = normalizeInput(text);
|
|
133
|
+
if (!FOUR_CHAR.test(idiom)) {
|
|
134
|
+
return { ok: false, reason: '请输入**四字成语**(不要空格或标点)。' };
|
|
135
|
+
}
|
|
136
|
+
if (!isKnownIdiom(idiom)) {
|
|
137
|
+
return { ok: false, reason: '开源词库中未收录该成语,请换一个常见四字成语。' };
|
|
138
|
+
}
|
|
139
|
+
if (used.has(idiom)) {
|
|
140
|
+
return { ok: false, reason: '这个成语本局已经用过了。' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const valid = getValidNext(prevIdiom, mode, used);
|
|
144
|
+
if (valid.some((v) => v.text === idiom)) return { ok: true };
|
|
145
|
+
|
|
146
|
+
const tail = lastChar(prevIdiom);
|
|
147
|
+
if (mode === 'char') {
|
|
148
|
+
return { ok: false, reason: `必须以「**${tail}**」开头(上一句尾字)。` };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const py = lastSyllableNoTone(prevIdiom);
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: `首字需**同音**(${py}),如「${tail}」音;也可同字「${tail}」开头。`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function promptLine(prevIdiom: string, mode: MatchMode): string {
|
|
159
|
+
const tail = lastChar(prevIdiom);
|
|
160
|
+
if (mode === 'char') {
|
|
161
|
+
return `请接「**${tail}**」字开头的四字成语`;
|
|
162
|
+
}
|
|
163
|
+
const py = lastSyllableNoTone(prevIdiom);
|
|
164
|
+
return `请接**同音**「${py}」(尾字「${tail}」)开头的四字成语`;
|
|
165
|
+
}
|
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 { registerChainHub } 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
|
+
registerChainHub(() => 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,64 @@
|
|
|
1
|
+
import type { Models, Plugin } from 'zhin.js';
|
|
2
|
+
import type { MatchMode } from './engine.js';
|
|
3
|
+
|
|
4
|
+
export type ChainSessionStatus = 'active' | 'won' | 'lost' | 'aborted';
|
|
5
|
+
|
|
6
|
+
declare module 'zhin.js' {
|
|
7
|
+
interface Models {
|
|
8
|
+
idiom_chain_sessions: {
|
|
9
|
+
id: string;
|
|
10
|
+
adapter: string;
|
|
11
|
+
endpoint: string;
|
|
12
|
+
channel_type: string;
|
|
13
|
+
channel_id: string;
|
|
14
|
+
channel_key: string;
|
|
15
|
+
player_id: string;
|
|
16
|
+
player_name: string;
|
|
17
|
+
last_idiom: string;
|
|
18
|
+
next_char: string;
|
|
19
|
+
match_mode: MatchMode;
|
|
20
|
+
used_idioms: string;
|
|
21
|
+
player_score: number;
|
|
22
|
+
bot_score: number;
|
|
23
|
+
streak: number;
|
|
24
|
+
best_streak: number;
|
|
25
|
+
wrong_count: number;
|
|
26
|
+
hints_used: number;
|
|
27
|
+
turn: string;
|
|
28
|
+
status: ChainSessionStatus;
|
|
29
|
+
board_message_id: string;
|
|
30
|
+
updated_at: number;
|
|
31
|
+
created_at: number;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ChainSessionRow = Models['idiom_chain_sessions'];
|
|
37
|
+
|
|
38
|
+
export function registerModels(plugin: Plugin): void {
|
|
39
|
+
plugin.defineModel('idiom_chain_sessions', {
|
|
40
|
+
id: { type: 'text', primary: true },
|
|
41
|
+
adapter: { type: 'text', nullable: false },
|
|
42
|
+
endpoint: { type: 'text', nullable: false },
|
|
43
|
+
channel_type: { type: 'text', nullable: false },
|
|
44
|
+
channel_id: { type: 'text', nullable: false },
|
|
45
|
+
channel_key: { type: 'text', nullable: false },
|
|
46
|
+
player_id: { type: 'text', nullable: false },
|
|
47
|
+
player_name: { type: 'text', default: '' },
|
|
48
|
+
last_idiom: { type: 'text', default: '' },
|
|
49
|
+
next_char: { type: 'text', default: '' },
|
|
50
|
+
match_mode: { type: 'text', default: 'pinyin' },
|
|
51
|
+
used_idioms: { type: 'text', default: '[]' },
|
|
52
|
+
player_score: { type: 'integer', default: 0 },
|
|
53
|
+
bot_score: { type: 'integer', default: 0 },
|
|
54
|
+
streak: { type: 'integer', default: 0 },
|
|
55
|
+
best_streak: { type: 'integer', default: 0 },
|
|
56
|
+
wrong_count: { type: 'integer', default: 0 },
|
|
57
|
+
hints_used: { type: 'integer', default: 0 },
|
|
58
|
+
turn: { type: 'text', default: 'player' },
|
|
59
|
+
status: { type: 'text', default: 'active' },
|
|
60
|
+
board_message_id: { type: 'text', default: '' },
|
|
61
|
+
updated_at: { type: 'integer', default: 0 },
|
|
62
|
+
created_at: { type: 'integer', default: 0 },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Database, DatabaseFeature, Message, Models, RelatedModel } from 'zhin.js';
|
|
2
|
+
import { channelKey, generateSessionId } from '@zhin.js/game-shared';
|
|
3
|
+
import type { MatchMode } from './engine.js';
|
|
4
|
+
import type { ChainSessionRow } from './models.js';
|
|
5
|
+
|
|
6
|
+
export type ChainDatabase = Database<unknown, Models, string>;
|
|
7
|
+
|
|
8
|
+
function getModel(db: ChainDatabase) {
|
|
9
|
+
const model = db.models.get('idiom_chain_sessions');
|
|
10
|
+
if (!model) throw new Error('idiom_chain_sessions not registered');
|
|
11
|
+
return model as RelatedModel<unknown, Models, 'idiom_chain_sessions'>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseUsed(value: string | string[] | unknown): Set<string> {
|
|
15
|
+
if (Array.isArray(value)) return new Set(value.filter((x): x is string => typeof x === 'string'));
|
|
16
|
+
if (typeof value !== 'string' || !value) return new Set();
|
|
17
|
+
try {
|
|
18
|
+
const v = JSON.parse(value);
|
|
19
|
+
return Array.isArray(v) ? new Set(v.filter((x): x is string => typeof x === 'string')) : new Set();
|
|
20
|
+
} catch {
|
|
21
|
+
return new Set();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function serializeUsed(set: Set<string>): string {
|
|
26
|
+
return JSON.stringify([...set]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class SessionService {
|
|
30
|
+
constructor(private readonly db: ChainDatabase) {}
|
|
31
|
+
|
|
32
|
+
async getActiveByChannel(channel: string): Promise<ChainSessionRow | null> {
|
|
33
|
+
const rows = await getModel(this.db).findAll({ channel_key: channel, status: 'active' });
|
|
34
|
+
return rows[0] ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async getActiveForUser(channel: string, userId: string): Promise<ChainSessionRow | null> {
|
|
38
|
+
const row = await this.getActiveByChannel(channel);
|
|
39
|
+
if (!row || row.player_id !== userId) return null;
|
|
40
|
+
return row;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getById(id: string): Promise<ChainSessionRow | null> {
|
|
44
|
+
return getModel(this.db).findOne({ id });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async getActiveByBoardMessageId(messageId: string): Promise<ChainSessionRow | null> {
|
|
48
|
+
if (!messageId) return null;
|
|
49
|
+
const rows = await getModel(this.db).findAll({ status: 'active' });
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
const stored = row.board_message_id;
|
|
52
|
+
if (!stored) continue;
|
|
53
|
+
if (stored === messageId || stored.endsWith(`:${messageId}`)) return row;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async createSession(
|
|
59
|
+
message: Message<any>,
|
|
60
|
+
starter: { text: string; nextChar: string; used: string[]; matchMode: MatchMode },
|
|
61
|
+
): Promise<ChainSessionRow> {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const row: ChainSessionRow = {
|
|
64
|
+
id: generateSessionId(),
|
|
65
|
+
adapter: String(message.$adapter),
|
|
66
|
+
endpoint: message.$endpoint,
|
|
67
|
+
channel_type: message.$channel.type,
|
|
68
|
+
channel_id: message.$channel.id,
|
|
69
|
+
channel_key: channelKey(message),
|
|
70
|
+
player_id: message.$sender.id,
|
|
71
|
+
player_name: message.$sender.name?.trim() || message.$sender.id,
|
|
72
|
+
last_idiom: starter.text,
|
|
73
|
+
next_char: starter.nextChar,
|
|
74
|
+
match_mode: starter.matchMode,
|
|
75
|
+
used_idioms: serializeUsed(new Set(starter.used)),
|
|
76
|
+
player_score: 0,
|
|
77
|
+
bot_score: 0,
|
|
78
|
+
streak: 0,
|
|
79
|
+
best_streak: 0,
|
|
80
|
+
wrong_count: 0,
|
|
81
|
+
hints_used: 0,
|
|
82
|
+
turn: 'player',
|
|
83
|
+
status: 'active',
|
|
84
|
+
board_message_id: '',
|
|
85
|
+
updated_at: now,
|
|
86
|
+
created_at: now,
|
|
87
|
+
};
|
|
88
|
+
await getModel(this.db).create(row);
|
|
89
|
+
return row;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async updateSession(id: string, patch: Partial<ChainSessionRow>): Promise<void> {
|
|
93
|
+
await getModel(this.db).updateWhere({ id }, { ...patch, updated_at: Date.now() });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async abortStale(idleMs: number): Promise<number> {
|
|
97
|
+
const cutoff = Date.now() - idleMs;
|
|
98
|
+
const model = getModel(this.db);
|
|
99
|
+
const rows = await model.findAll({ status: 'active' });
|
|
100
|
+
let n = 0;
|
|
101
|
+
for (const row of rows) {
|
|
102
|
+
if (row.updated_at < cutoff) {
|
|
103
|
+
await model.updateWhere({ id: row.id }, { status: 'aborted', updated_at: Date.now() });
|
|
104
|
+
n++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return n;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createServices(db: ChainDatabase): SessionService {
|
|
112
|
+
return new SessionService(db);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function resolveGameDatabase(feature: DatabaseFeature): ChainDatabase {
|
|
116
|
+
return feature.db;
|
|
117
|
+
}
|