@zhin.js/plugin-idiom-chain 1.0.0 → 1.0.2

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.
Files changed (67) hide show
  1. package/README.md +31 -0
  2. package/commands/chain/[action:string=].ts +15 -0
  3. package/lib/chain-command.d.ts +4 -3
  4. package/lib/chain-command.js +5 -1
  5. package/lib/chain-command.js.map +1 -1
  6. package/lib/engine.d.ts +0 -1
  7. package/lib/game-flow.d.ts +6 -7
  8. package/lib/game-flow.js +27 -22
  9. package/lib/game-flow.js.map +1 -1
  10. package/lib/idiom-provider.d.ts +0 -1
  11. package/lib/idiom-provider.js +3 -2
  12. package/lib/idiom-provider.js.map +1 -1
  13. package/lib/index.d.ts +8 -2
  14. package/lib/index.js +8 -25
  15. package/lib/index.js.map +1 -1
  16. package/lib/memory-db.d.ts +6 -0
  17. package/lib/memory-db.js +18 -0
  18. package/lib/memory-db.js.map +1 -0
  19. package/lib/models.d.ts +5 -4
  20. package/lib/models.js +2 -2
  21. package/lib/models.js.map +1 -1
  22. package/lib/runtime-store.d.ts +6 -0
  23. package/lib/runtime-store.js +7 -0
  24. package/lib/runtime-store.js.map +1 -0
  25. package/lib/session-service.d.ts +1 -3
  26. package/lib/session-service.js +3 -9
  27. package/lib/session-service.js.map +1 -1
  28. package/lib/view.d.ts +2 -3
  29. package/lib/view.js +5 -3
  30. package/lib/view.js.map +1 -1
  31. package/middlewares/chain-alias.ts +18 -0
  32. package/middlewares/chain-text.ts +75 -0
  33. package/package.json +45 -10
  34. package/plugin.ts +64 -0
  35. package/schema.json +6 -0
  36. package/src/chain-command.ts +14 -5
  37. package/src/game-flow.ts +33 -29
  38. package/src/idiom-provider.ts +3 -2
  39. package/src/index.ts +9 -32
  40. package/src/memory-db.ts +22 -0
  41. package/src/models.ts +7 -4
  42. package/src/runtime-store.ts +9 -0
  43. package/src/session-service.ts +4 -9
  44. package/src/view.ts +6 -3
  45. package/lib/chain-command.d.ts.map +0 -1
  46. package/lib/commands.d.ts +0 -6
  47. package/lib/commands.d.ts.map +0 -1
  48. package/lib/commands.js +0 -94
  49. package/lib/commands.js.map +0 -1
  50. package/lib/data/idioms.d.ts +0 -8
  51. package/lib/data/idioms.d.ts.map +0 -1
  52. package/lib/data/idioms.js +0 -422
  53. package/lib/data/idioms.js.map +0 -1
  54. package/lib/engine.d.ts.map +0 -1
  55. package/lib/game-flow.d.ts.map +0 -1
  56. package/lib/hub-register.d.ts +0 -5
  57. package/lib/hub-register.d.ts.map +0 -1
  58. package/lib/hub-register.js +0 -31
  59. package/lib/hub-register.js.map +0 -1
  60. package/lib/idiom-provider.d.ts.map +0 -1
  61. package/lib/index.d.ts.map +0 -1
  62. package/lib/models.d.ts.map +0 -1
  63. package/lib/session-service.d.ts.map +0 -1
  64. package/lib/view.d.ts.map +0 -1
  65. package/plugin.yml +0 -2
  66. package/src/commands.ts +0 -112
  67. package/src/hub-register.ts +0 -32
@@ -0,0 +1,18 @@
1
+ import {
2
+ defineGameCommandAliasMiddleware,
3
+ messageFromCommandInput,
4
+ normalizeChainAction,
5
+ } from '@zhin.js/game-kit';
6
+ import { CHAIN_HELP, runChainCommandText } from '../src/chain-command.js';
7
+ import { resolveGameServices } from '../src/runtime-store.js';
8
+
9
+ export default defineGameCommandAliasMiddleware({
10
+ aliases: ['接龙', 'chain'],
11
+ async run(action, input, context) {
12
+ const normalized = normalizeChainAction(String(action ?? ''));
13
+ if (!normalized || normalized === 'help') return CHAIN_HELP;
14
+ const services = resolveGameServices(context);
15
+ const message = messageFromCommandInput(input);
16
+ return runChainCommandText(services, message, normalized);
17
+ },
18
+ });
@@ -0,0 +1,75 @@
1
+ import { defineMiddleware } from '@zhin.js/middleware';
2
+ import type { Message } from '@zhin.js/core/runtime';
3
+ import {
4
+ buildChoiceFallbackMap,
5
+ channelKey,
6
+ messageFromCommandInput,
7
+ parseChoicePayload,
8
+ resolveGameTextPayload,
9
+ } from '@zhin.js/game-kit';
10
+ import { resolveGameServices } from '../src/runtime-store.js';
11
+ import { CHAIN_PREFIX, handleChoice, processIdiomText } from '../src/game-flow.js';
12
+
13
+ /**
14
+ * 文本入口:按钮 payload(`chain:session:choiceId`)、数字 fallback
15
+ * (『1提示 2跳过 3认输』,对应 view 的 fallbackHint),其余按成语接龙作答。
16
+ */
17
+ export default defineMiddleware<Message>({
18
+ target: 'inbound',
19
+ async handle(context, next) {
20
+ const raw = context.input.content?.trim() ?? '';
21
+ if (!raw) {
22
+ await next();
23
+ return;
24
+ }
25
+ const services = resolveGameServices(context);
26
+ const message = messageFromCommandInput(context.input);
27
+ const ch = channelKey(message);
28
+
29
+ // 直接 payload(QQ 指令预填 / Sandbox action→text)
30
+ const payloadFromText = resolveGameTextPayload(raw);
31
+ if (payloadFromText?.startsWith(`${CHAIN_PREFIX}:`)) {
32
+ const parsed = parseChoicePayload(payloadFromText, CHAIN_PREFIX);
33
+ if (parsed) {
34
+ const session = await services.getById(parsed.sessionId);
35
+ if (session?.channel_key === ch) {
36
+ const reply = await handleChoice(null, services, message, parsed.sessionId, parsed.choiceId);
37
+ if (reply) await context.input.$reply(reply);
38
+ return;
39
+ }
40
+ }
41
+ await next();
42
+ return;
43
+ }
44
+
45
+ const session = await services.getActiveForUser(ch, message.$sender.id);
46
+ if (!session) {
47
+ await next();
48
+ return;
49
+ }
50
+
51
+ if (session.status === 'active') {
52
+ const map = buildChoiceFallbackMap(CHAIN_PREFIX, session.id, [
53
+ { id: 'hint', label: '提示' },
54
+ { id: 'skip', label: '跳过' },
55
+ { id: 'quit', label: '认输' },
56
+ ]);
57
+ const payload = resolveGameTextPayload(raw, map);
58
+ const parsed = payload ? parseChoicePayload(payload, CHAIN_PREFIX) : null;
59
+ if (parsed?.sessionId === session.id) {
60
+ const reply = await handleChoice(null, services, message, session.id, parsed.choiceId);
61
+ if (reply) await context.input.$reply(reply);
62
+ return;
63
+ }
64
+ }
65
+
66
+ // 仅把 2-8 个汉字的纯文本当成语作答,其余放行
67
+ if (/^[\u4e00-\u9fff]{2,8}$/.test(raw)) {
68
+ const reply = await processIdiomText(null, services, message, raw);
69
+ if (reply) await context.input.$reply(reply);
70
+ return;
71
+ }
72
+
73
+ await next();
74
+ },
75
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zhin.js/plugin-idiom-chain",
3
- "version": "1.0.0",
4
- "description": "Zhin.js 成语接龙 — 四字成语文字接龙",
3
+ "version": "1.0.2",
4
+ "description": "Zhin.js 成语接龙 — 四字成语文字接龙 (Plugin Runtime)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -14,25 +14,60 @@
14
14
  "./package.json": "./package.json"
15
15
  },
16
16
  "files": [
17
+ "plugin.ts",
18
+ "schema.json",
19
+ "commands",
20
+ "middlewares",
17
21
  "src",
18
22
  "lib",
19
- "plugin.yml",
20
23
  "README.md"
21
24
  ],
22
25
  "license": "MIT",
23
- "peerDependencies": {
24
- "zhin.js": "4.1.0"
25
- },
26
26
  "dependencies": {
27
27
  "chinese-idiom-chengyu": "^1.1.0",
28
- "@zhin.js/game-shared": "1.0.0"
28
+ "@zhin.js/command": "1.0.1",
29
+ "@zhin.js/core": "1.3.5",
30
+ "@zhin.js/game-kit": "1.0.2",
31
+ "@zhin.js/middleware": "1.0.1",
32
+ "@zhin.js/plugin-runtime": "1.0.1"
29
33
  },
30
34
  "devDependencies": {
31
35
  "typescript": "^6.0.3",
32
- "zhin.js": "4.1.0"
36
+ "vitest": "^4.1.10"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/zhinjs/zhin.git",
41
+ "directory": "plugins/games/idiom-chain"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "registry": "https://registry.npmjs.org"
46
+ },
47
+ "engines": {
48
+ "node": "^20.19.0 || >=22.12.0"
49
+ },
50
+ "zhin": {
51
+ "protocol": 1,
52
+ "type": "plugin",
53
+ "entry": "./plugin.ts",
54
+ "engine": "^1.0.0",
55
+ "runtime": "trusted",
56
+ "features": [
57
+ {
58
+ "package": "@zhin.js/command",
59
+ "api": "^1.0.0"
60
+ },
61
+ {
62
+ "package": "@zhin.js/middleware",
63
+ "api": "^1.0.0"
64
+ }
65
+ ],
66
+ "plugins": []
33
67
  },
34
68
  "scripts": {
35
- "build": "tsc --build",
36
- "clean": "rimraf lib"
69
+ "build": "tsc",
70
+ "clean": "rimraf lib",
71
+ "test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/games/idiom-chain/tests"
37
72
  }
38
73
  }
package/plugin.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { definePlugin, databaseHostToken, scheduleHostToken } from '@zhin.js/plugin-runtime';
2
+ import {
3
+ registerRuntimeGame,
4
+ initGameRecordHost,
5
+ DEFAULT_GAME_STALE_CRON,
6
+ DEFAULT_GAME_STALE_IDLE_MS,
7
+ } from '@zhin.js/game-kit';
8
+ import { mountChainHostServices, mountChainMemoryServices } from './src/memory-db.js';
9
+ import { gameServicesToken } from './src/runtime-store.js';
10
+ import type { SessionService } from './src/session-service.js';
11
+
12
+ /**
13
+ * Plugin Runtime:
14
+ * - Commands under `commands/` are authoritative (help + start/continue/quit via in-memory DB).
15
+ * - DB: prefer databaseHostToken; else in-memory SessionService.
16
+ * - Text middleware under `middlewares/` handles idiom answer payloads.
17
+ */
18
+ export default definePlugin({
19
+ name: 'idiom-chain',
20
+ metadata: {
21
+ displayName: 'Idiom Chain',
22
+ },
23
+ setup(context) {
24
+ let services: SessionService;
25
+ if (context.resources.has(databaseHostToken)) {
26
+ const host = context.resources.use(databaseHostToken);
27
+ services = mountChainHostServices(host);
28
+ context.lifecycle.add(initGameRecordHost(host));
29
+ } else {
30
+ services = mountChainMemoryServices();
31
+ }
32
+ context.resources.provide(gameServicesToken, services);
33
+ const disposeHub = registerRuntimeGame({
34
+ id: 'chain',
35
+ title: '成语接龙',
36
+ icon: '📜',
37
+ description: '四字成语接龙(同音/同字)',
38
+ commandPrefix: '/接龙',
39
+ quickStart: 'start_pinyin',
40
+ aliases: ['chain'],
41
+ menus: [
42
+ { id: 'start_pinyin', label: '🎮 同音接龙' },
43
+ { id: 'start_char', label: '📝 同字接龙' },
44
+ { id: 'continue', label: '🔄 继续' },
45
+ { id: 'help', label: '📖 玩法说明' },
46
+ ],
47
+ });
48
+ context.lifecycle.add(disposeHub);
49
+
50
+ if (context.resources.has(scheduleHostToken)) {
51
+ const schedule = context.resources.use(scheduleHostToken);
52
+ const disposeCron = schedule.register({
53
+ id: 'chain/abort-stale',
54
+ cron: DEFAULT_GAME_STALE_CRON,
55
+ description: 'Abort stale idiom-chain sessions',
56
+ async execute() {
57
+ if (!services.abortStale) return;
58
+ await services.abortStale(DEFAULT_GAME_STALE_IDLE_MS);
59
+ },
60
+ });
61
+ context.lifecycle.add(disposeCron);
62
+ }
63
+ },
64
+ });
package/schema.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "properties": {}
6
+ }
@@ -1,8 +1,8 @@
1
- import type { Message, Plugin } from 'zhin.js';
2
- import { channelKey } from '@zhin.js/game-shared';
1
+ import type { Message, Plugin } from '@zhin.js/core';
2
+ import { channelKey } from '@zhin.js/game-kit';
3
3
  import { continueGame, startGame } from './game-flow.js';
4
- import { idiomCount, modeLabel, promptLine } from './engine.js';
5
- import type { MatchMode } from './engine.js';
4
+ import { idiomCount, modeLabel, promptLine, type MatchMode } from './engine.js';
5
+
6
6
  import type { SessionService } from './session-service.js';
7
7
 
8
8
  export const CHAIN_HELP = [
@@ -19,7 +19,7 @@ export const CHAIN_HELP = [
19
19
  ].join('\n');
20
20
 
21
21
  export async function runChainCommand(
22
- plugin: Plugin,
22
+ plugin: Plugin | null,
23
23
  services: SessionService,
24
24
  message: Message<any>,
25
25
  action: string,
@@ -55,3 +55,12 @@ export async function runChainCommand(
55
55
 
56
56
  return `未知子命令:${action}\n\n${CHAIN_HELP}`;
57
57
  }
58
+
59
+ /** Plugin Runtime / smoke: text-only, no Adapter.editMessage. */
60
+ export async function runChainCommandText(
61
+ services: SessionService,
62
+ message: Message<any>,
63
+ action: string,
64
+ ): Promise<string> {
65
+ return (await runChainCommand(null, services, message, action)) ?? '';
66
+ }
package/src/game-flow.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Adapter, Message, Plugin } from 'zhin.js';
1
+ import type { Adapter, Message, Plugin } from '@zhin.js/core';
2
+ import { plainTextFromSendContent } from '@zhin.js/game-kit';
2
3
  import {
3
4
  CHAIN_PREFIX,
4
5
  getGloss,
@@ -21,13 +22,15 @@ function sessionMode(session: ChainSessionRow): MatchMode {
21
22
  }
22
23
 
23
24
  export async function sendOrEditView(
24
- plugin: Plugin,
25
+ plugin: Plugin | null,
25
26
  services: SessionService,
26
27
  message: Message<any>,
27
28
  session: ChainSessionRow,
28
29
  eventLines: string[] = [],
29
- ): Promise<void> {
30
- const content = buildChainView(session, eventLines);
30
+ ): Promise<string | void> {
31
+ const content = buildChainView(session, eventLines, message.$channel.type);
32
+ if (!plugin) return plainTextFromSendContent(content);
33
+
31
34
  const adapter = plugin.root.inject(message.$adapter) as Adapter;
32
35
 
33
36
  if (session.board_message_id) {
@@ -83,7 +86,7 @@ async function botTurn(
83
86
  }
84
87
 
85
88
  export async function startGame(
86
- plugin: Plugin,
89
+ plugin: Plugin | null,
87
90
  services: SessionService,
88
91
  message: Message<any>,
89
92
  matchMode: MatchMode = 'pinyin',
@@ -108,15 +111,15 @@ export async function startGame(
108
111
  });
109
112
 
110
113
  const gloss = starter.gloss ?? getGloss(starter.text);
111
- await sendOrEditView(plugin, services, message, session, [
114
+ const text = await sendOrEditView(plugin, services, message, session, [
112
115
  `🎬 ${modeLabel(matchMode)}开局!我先出:**${starter.text}**${gloss ? `(${gloss})` : ''}`,
113
116
  promptLine(starter.text, matchMode),
114
117
  ]);
115
- return undefined;
118
+ return typeof text === 'string' ? text : undefined;
116
119
  }
117
120
 
118
121
  export async function continueGame(
119
- plugin: Plugin,
122
+ plugin: Plugin | null,
120
123
  services: SessionService,
121
124
  message: Message<any>,
122
125
  ): Promise<string> {
@@ -125,12 +128,13 @@ export async function continueGame(
125
128
  message.$sender.id,
126
129
  );
127
130
  if (!session) return '你没有进行中的接龙,发送「接龙 开始」。';
128
- await sendOrEditView(plugin, services, message, session);
131
+ const text = await sendOrEditView(plugin, services, message, session);
132
+ if (typeof text === 'string') return text;
129
133
  return '已刷新接龙界面。';
130
134
  }
131
135
 
132
136
  export async function processIdiomText(
133
- plugin: Plugin,
137
+ plugin: Plugin | null,
134
138
  services: SessionService,
135
139
  message: Message<any>,
136
140
  raw: string,
@@ -153,13 +157,14 @@ export async function processIdiomText(
153
157
  streak: 0,
154
158
  });
155
159
  const updated = (await services.getById(session.id))!;
156
- await sendOrEditView(plugin, services, message, updated, [check.reason!, '失误过多,本局结束。']);
157
- return null;
160
+ return (await sendOrEditView(plugin, services, message, updated, [
161
+ check.reason!,
162
+ '失误过多,本局结束。',
163
+ ])) ?? null;
158
164
  }
159
165
  await services.updateSession(session.id, { wrong_count: wrong, streak: 0 });
160
166
  const updated = (await services.getById(session.id))!;
161
- await sendOrEditView(plugin, services, message, updated, [check.reason!]);
162
- return null;
167
+ return (await sendOrEditView(plugin, services, message, updated, [check.reason!])) ?? null;
163
168
  }
164
169
 
165
170
  used.add(idiom);
@@ -181,12 +186,14 @@ export async function processIdiomText(
181
186
  const userLine = `✅ 你:${idiom}${gloss ? `(${gloss})` : ''}`;
182
187
 
183
188
  const botResult = await botTurn(services, current, used);
184
- await sendOrEditView(plugin, services, message, botResult.session, [userLine, ...botResult.lines]);
185
- return null;
189
+ return (await sendOrEditView(plugin, services, message, botResult.session, [
190
+ userLine,
191
+ ...botResult.lines,
192
+ ])) ?? null;
186
193
  }
187
194
 
188
195
  export async function handleChoice(
189
- plugin: Plugin,
196
+ plugin: Plugin | null,
190
197
  services: SessionService,
191
198
  message: Message<any>,
192
199
  sessionId: string,
@@ -214,11 +221,10 @@ export async function handleChoice(
214
221
  });
215
222
  const updated = (await services.getById(session.id))!;
216
223
  const gloss = starter.gloss ?? getGloss(starter.text);
217
- await sendOrEditView(plugin, services, message, updated, [
224
+ return (await sendOrEditView(plugin, services, message, updated, [
218
225
  `🎬 新一局!我先出:**${starter.text}**${gloss ? `(${gloss})` : ''}`,
219
226
  promptLine(starter.text, mode),
220
- ]);
221
- return null;
227
+ ])) ?? null;
222
228
  }
223
229
 
224
230
  if (session.status !== 'active') {
@@ -233,17 +239,15 @@ export async function handleChoice(
233
239
  await sendOrEditView(plugin, services, message, session, ['💡 词库中暂无可用提示,你赢了!']);
234
240
  await services.updateSession(session.id, { status: 'won', player_score: session.player_score + 1 });
235
241
  const updated = (await services.getById(session.id))!;
236
- await sendOrEditView(plugin, services, message, updated);
237
- return null;
242
+ return (await sendOrEditView(plugin, services, message, updated)) ?? null;
238
243
  }
239
244
  const gloss = getGloss(hint);
240
245
  await services.updateSession(session.id, { hints_used: session.hints_used + 1, streak: 0 });
241
246
  const updated = (await services.getById(session.id))!;
242
- await sendOrEditView(plugin, services, message, updated, [
247
+ return (await sendOrEditView(plugin, services, message, updated, [
243
248
  `💡 提示:可试 **${hint}**${gloss ? `(${gloss})` : ''}`,
244
249
  '(使用提示会清零连击)',
245
- ]);
246
- return null;
250
+ ])) ?? null;
247
251
  }
248
252
 
249
253
  if (choiceId === 'skip') {
@@ -253,15 +257,15 @@ export async function handleChoice(
253
257
  streak: 0,
254
258
  });
255
259
  const updated = (await services.getById(session.id))!;
256
- await sendOrEditView(plugin, services, message, updated, ['⏭️ 你选择跳过,机器人得一分。']);
257
- return null;
260
+ return (await sendOrEditView(plugin, services, message, updated, [
261
+ '⏭️ 你选择跳过,机器人得一分。',
262
+ ])) ?? null;
258
263
  }
259
264
 
260
265
  if (choiceId === 'quit') {
261
266
  await services.updateSession(session.id, { status: 'lost', bot_score: session.bot_score + 1 });
262
267
  const updated = (await services.getById(session.id))!;
263
- await sendOrEditView(plugin, services, message, updated, ['🏳️ 你认输了。']);
264
- return null;
268
+ return (await sendOrEditView(plugin, services, message, updated, ['🏳️ 你认输了。'])) ?? null;
265
269
  }
266
270
 
267
271
  return '未知操作。';
@@ -3,6 +3,7 @@
3
3
  * @see https://www.npmjs.com/package/chinese-idiom-chengyu
4
4
  */
5
5
  import { createRequire } from 'node:module';
6
+ import { secureRandomItem } from '@zhin.js/game-kit';
6
7
 
7
8
  const require = createRequire(import.meta.url);
8
9
 
@@ -101,12 +102,12 @@ export function pickBotIdiom(
101
102
  ): IdiomEntry | null {
102
103
  const candidates = getValidNext(prevIdiom, mode, used);
103
104
  if (!candidates.length) return null;
104
- return candidates[Math.floor(Math.random() * candidates.length)]!;
105
+ return secureRandomItem(candidates);
105
106
  }
106
107
 
107
108
  export function pickStarterIdiom(used: Set<string>): IdiomEntry {
108
109
  const pool = buildFourCharPool().filter((w) => !used.has(w));
109
- const text = pool[Math.floor(Math.random() * pool.length)] ?? '一心一意';
110
+ const text = pool.length > 0 ? secureRandomItem(pool) : '一心一意';
110
111
  return { text, gloss: getGloss(text) };
111
112
  }
112
113
 
package/src/index.ts CHANGED
@@ -1,32 +1,9 @@
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({ 模块: '成语接龙', 状态: '已加载' }));
1
+ export { CHAIN_HELP } from './chain-command.js';
2
+ export { gameServicesToken, resolveGameServices } from './runtime-store.js';
3
+ export { createInMemoryChainDb, mountChainMemoryServices } from './memory-db.js';
4
+
5
+ /**
6
+ * 已由 plugin.ts 接线:commands/ 命令、middlewares/ 文本与选项中间件、
7
+ * 游戏大厅注册(registerRuntimeGame)、过期会话 cron(scheduleHostToken)、
8
+ * host DatabaseHost(databaseHostToken)。仍未接:interactive 按钮回调。
9
+ */
@@ -0,0 +1,22 @@
1
+ import { createHostGameDb, createInMemoryGameDb } from '@zhin.js/game-kit';
2
+ import type { DatabaseHost } from '@zhin.js/plugin-runtime';
3
+ import { defineHostTables } from './models.js';
4
+ import { createServices, type ChainDatabase, type SessionService } from './session-service.js';
5
+
6
+ const CHAIN_TABLES = ['idiom_chain_sessions'] as const;
7
+
8
+ /** In-memory idiom_chain_sessions for Plugin Runtime slice-2 (no DatabaseFeature). */
9
+ export function createInMemoryChainDb(): ChainDatabase {
10
+ return createInMemoryGameDb(CHAIN_TABLES) as unknown as ChainDatabase;
11
+ }
12
+
13
+ export function mountChainMemoryServices(): SessionService {
14
+ const services = createServices(createInMemoryChainDb());
15
+ return services;
16
+ }
17
+
18
+ export function mountChainHostServices(host: DatabaseHost): SessionService {
19
+ defineHostTables(host);
20
+ const services = createServices(createHostGameDb(host, CHAIN_TABLES) as unknown as ChainDatabase);
21
+ return services;
22
+ }
package/src/models.ts CHANGED
@@ -1,9 +1,9 @@
1
- import type { Models, Plugin } from 'zhin.js';
1
+ import type { Models } from '@zhin.js/core';
2
2
  import type { MatchMode } from './engine.js';
3
3
 
4
4
  export type ChainSessionStatus = 'active' | 'won' | 'lost' | 'aborted';
5
5
 
6
- declare module 'zhin.js' {
6
+ declare module '@zhin.js/core' {
7
7
  interface Models {
8
8
  idiom_chain_sessions: {
9
9
  id: string;
@@ -35,8 +35,11 @@ declare module 'zhin.js' {
35
35
 
36
36
  export type ChainSessionRow = Models['idiom_chain_sessions'];
37
37
 
38
- export function registerModels(plugin: Plugin): void {
39
- plugin.defineModel('idiom_chain_sessions', {
38
+
39
+ export function defineHostTables(
40
+ db: { define: (name: string, definition: Record<string, unknown>) => void },
41
+ ): void {
42
+ db.define('idiom_chain_sessions', {
40
43
  id: { type: 'text', primary: true },
41
44
  adapter: { type: 'text', nullable: false },
42
45
  endpoint: { type: 'text', nullable: false },
@@ -0,0 +1,9 @@
1
+ import { createToken } from '@zhin.js/plugin-runtime';
2
+ import type { SessionService } from './session-service.js';
3
+
4
+ /** Owner-scoped service consumed by discovered commands and middleware. */
5
+ export const gameServicesToken = createToken<SessionService>('zhin.game.idiom-chain.services');
6
+
7
+ export function resolveGameServices(context: { use<T>(token: typeof gameServicesToken): T }): SessionService {
8
+ return context.use(gameServicesToken) as SessionService;
9
+ }
@@ -1,5 +1,5 @@
1
- import type { Database, DatabaseFeature, Message, Models, RelatedModel } from 'zhin.js';
2
- import { channelKey, generateSessionId } from '@zhin.js/game-shared';
1
+ import type { Database, Message, Models, RelatedModel } from '@zhin.js/core';
2
+ import { channelKey, generateSessionId, boardMessageMatches } from '@zhin.js/game-kit';
3
3
  import type { MatchMode } from './engine.js';
4
4
  import type { ChainSessionRow } from './models.js';
5
5
 
@@ -46,11 +46,9 @@ export class SessionService {
46
46
 
47
47
  async getActiveByBoardMessageId(messageId: string): Promise<ChainSessionRow | null> {
48
48
  if (!messageId) return null;
49
- const rows = await getModel(this.db).findAll({ status: 'active' });
49
+ const rows = await getModel(this.db).findAll({});
50
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;
51
+ if (boardMessageMatches(row.board_message_id ?? '', messageId)) return row;
54
52
  }
55
53
  return null;
56
54
  }
@@ -112,6 +110,3 @@ export function createServices(db: ChainDatabase): SessionService {
112
110
  return new SessionService(db);
113
111
  }
114
112
 
115
- export function resolveGameDatabase(feature: DatabaseFeature): ChainDatabase {
116
- return feature.db;
117
- }
package/src/view.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { buildChoiceKeyboard } from '@zhin.js/game-shared';
2
- import type { SendContent } from 'zhin.js';
1
+ import { buildChoiceKeyboard } from '@zhin.js/game-kit';
2
+ import type { SendContent } from '@zhin.js/core';
3
3
  import type { ChainSessionRow } from './models.js';
4
4
  import { CHAIN_PREFIX, getGloss, idiomCount, modeLabel, promptLine } from './engine.js';
5
5
 
@@ -12,6 +12,7 @@ function sessionMode(session: ChainSessionRow): 'char' | 'pinyin' {
12
12
  export function buildChainView(
13
13
  session: ChainSessionRow,
14
14
  eventLines: string[] = [],
15
+ channelType?: string,
15
16
  ): SendContent {
16
17
  const terminal = session.status !== 'active';
17
18
  const mode = sessionMode(session);
@@ -44,7 +45,7 @@ export function buildChainView(
44
45
  }
45
46
 
46
47
  const choices = terminal
47
- ? [{ id: 'restart', label: '🔄 再来一局', style: 'primary' as const }]
48
+ ? [{ id: 'restart', label: '🔄 再来一局', style: 'primary' as const, keepEnabledWhenTerminal: true }]
48
49
  : [
49
50
  { id: 'hint', label: '💡 提示', style: 'secondary' as const },
50
51
  { id: 'skip', label: '⏭️ 跳过', style: 'secondary' as const },
@@ -59,6 +60,8 @@ export function buildChainView(
59
60
  terminal,
60
61
  buttonsPerRow: 3,
61
62
  fallbackHint: '回复成语,或 1提示 2跳过 3认输',
63
+ interactionProfile: terminal ? 'terminal' : 'gameplay',
64
+ channelType,
62
65
  });
63
66
  }
64
67
 
@@ -1 +0,0 @@
1
- {"version":3,"file":"chain-command.d.ts","sourceRoot":"","sources":["../src/chain-command.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAK/C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAE3D,eAAO,MAAM,UAAU,QAWX,CAAC;AAEb,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,cAAc,EACxB,OAAO,EAAE,OAAO,CAAC,GAAG,CAAC,EACrB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CA+B7B"}
package/lib/commands.d.ts DELETED
@@ -1,6 +0,0 @@
1
- import { type Plugin } from 'zhin.js';
2
- import type { SessionService } from './session-service.js';
3
- export declare function registerCommands(plugin: Plugin, getServices: () => SessionService | null): void;
4
- export declare function registerInteractive(plugin: Plugin, getServices: () => SessionService | null): void;
5
- export declare function registerTextMiddleware(plugin: Plugin, getServices: () => SessionService | null): void;
6
- //# sourceMappingURL=commands.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../src/commands.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiD,KAAK,MAAM,EAAE,MAAM,SAAS,CAAC;AASrF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAoB3D,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,cAAc,GAAG,IAAI,GAAG,IAAI,CAG/F;AA4BD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,cAAc,GAAG,IAAI,GAAG,IAAI,CAUlG;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,cAAc,GAAG,IAAI,GAAG,IAAI,CAsCrG"}