@zhin.js/plugin-tic-tac-toe 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 (60) hide show
  1. package/commands/ttt/[action:string=].ts +15 -0
  2. package/lib/board-view.d.ts +3 -3
  3. package/lib/board-view.js +6 -2
  4. package/lib/board-view.js.map +1 -1
  5. package/lib/engine.d.ts +0 -1
  6. package/lib/game-flow.d.ts +6 -6
  7. package/lib/game-flow.js +61 -12
  8. package/lib/game-flow.js.map +1 -1
  9. package/lib/index.d.ts +8 -2
  10. package/lib/index.js +6 -34
  11. package/lib/index.js.map +1 -1
  12. package/lib/memory-db.d.ts +6 -0
  13. package/lib/memory-db.js +18 -0
  14. package/lib/memory-db.js.map +1 -0
  15. package/lib/models.d.ts +5 -4
  16. package/lib/models.js +5 -5
  17. package/lib/models.js.map +1 -1
  18. package/lib/player-label.d.ts +1 -2
  19. package/lib/runtime-store.d.ts +6 -0
  20. package/lib/runtime-store.js +7 -0
  21. package/lib/runtime-store.js.map +1 -0
  22. package/lib/session-service.d.ts +1 -3
  23. package/lib/session-service.js +4 -15
  24. package/lib/session-service.js.map +1 -1
  25. package/lib/ttt-command.d.ts +4 -3
  26. package/lib/ttt-command.js +8 -3
  27. package/lib/ttt-command.js.map +1 -1
  28. package/middlewares/ttt-alias.ts +18 -0
  29. package/middlewares/ttt-choice.ts +71 -0
  30. package/package.json +36 -18
  31. package/plugin.ts +64 -0
  32. package/schema.json +6 -0
  33. package/src/board-view.ts +8 -3
  34. package/src/game-flow.ts +71 -21
  35. package/src/index.ts +7 -41
  36. package/src/memory-db.ts +22 -0
  37. package/src/models.ts +10 -10
  38. package/src/player-label.ts +1 -1
  39. package/src/runtime-store.ts +9 -0
  40. package/src/session-service.ts +5 -13
  41. package/src/ttt-command.ts +15 -5
  42. package/lib/board-view.d.ts.map +0 -1
  43. package/lib/commands.d.ts +0 -6
  44. package/lib/commands.d.ts.map +0 -1
  45. package/lib/commands.js +0 -111
  46. package/lib/commands.js.map +0 -1
  47. package/lib/engine.d.ts.map +0 -1
  48. package/lib/game-flow.d.ts.map +0 -1
  49. package/lib/hub-register.d.ts +0 -5
  50. package/lib/hub-register.d.ts.map +0 -1
  51. package/lib/hub-register.js +0 -30
  52. package/lib/hub-register.js.map +0 -1
  53. package/lib/index.d.ts.map +0 -1
  54. package/lib/models.d.ts.map +0 -1
  55. package/lib/player-label.d.ts.map +0 -1
  56. package/lib/session-service.d.ts.map +0 -1
  57. package/lib/ttt-command.d.ts.map +0 -1
  58. package/plugin.yml +0 -2
  59. package/src/commands.ts +0 -129
  60. package/src/hub-register.ts +0 -31
@@ -0,0 +1,18 @@
1
+ import {
2
+ defineGameCommandAliasMiddleware,
3
+ messageFromCommandInput,
4
+ normalizeTttAction,
5
+ } from '@zhin.js/game-kit';
6
+ import { TTT_HELP, runTttCommandText } from '../src/ttt-command.js';
7
+ import { resolveGameServices } from '../src/runtime-store.js';
8
+
9
+ export default defineGameCommandAliasMiddleware({
10
+ aliases: ['井字棋', 'ttt'],
11
+ async run(action, input, context) {
12
+ const normalized = normalizeTttAction(String(action ?? ''));
13
+ if (!normalized || normalized === 'help') return TTT_HELP;
14
+ const services = resolveGameServices(context);
15
+ const message = messageFromCommandInput(input);
16
+ return runTttCommandText(services, message, normalized);
17
+ },
18
+ });
@@ -0,0 +1,71 @@
1
+ import { defineMiddleware } from '@zhin.js/middleware';
2
+ import type { Message } from '@zhin.js/core/runtime';
3
+ import {
4
+ channelKey,
5
+ messageFromCommandInput,
6
+ parseChoicePayload,
7
+ resolveGameTextPayload,
8
+ } from '@zhin.js/game-kit';
9
+ import { resolveGameServices } from '../src/runtime-store.js';
10
+ import { buildFallbackMap, TTT_PREFIX, parseTttPayload } from '../src/board-view.js';
11
+ import { parseBoard } from '../src/engine.js';
12
+ import { handleMove, restartFromTerminal } from '../src/game-flow.js';
13
+
14
+ /**
15
+ * 文本入口:按钮 payload(`ttt:session:0-8` 或 `ttt:session:restart`)
16
+ * 与裸数字 fallback(按空格 1-9 编号落子,对应视图『落子:回复数字 1-9(仅空格)』)。
17
+ */
18
+ export default defineMiddleware<Message>({
19
+ target: 'inbound',
20
+ async handle(context, next) {
21
+ const raw = context.input.content?.trim() ?? '';
22
+ if (!raw) {
23
+ await next();
24
+ return;
25
+ }
26
+ const services = resolveGameServices(context);
27
+ const message = messageFromCommandInput(context.input);
28
+ const ch = channelKey(message);
29
+
30
+ // 直接 payload(QQ 指令预填 / Sandbox action→text)
31
+ const payload = resolveGameTextPayload(raw);
32
+ if (payload?.startsWith(`${TTT_PREFIX}:`)) {
33
+ const restart = parseChoicePayload(payload, TTT_PREFIX);
34
+ if (restart?.choiceId === 'restart') {
35
+ const session = await services.session.getById(restart.sessionId);
36
+ if (session?.channel_key === ch) {
37
+ const reply = await restartFromTerminal(null, services, message, restart.sessionId);
38
+ if (reply) await context.input.$reply(reply);
39
+ return;
40
+ }
41
+ }
42
+ const move = parseTttPayload(payload);
43
+ if (move) {
44
+ const session = await services.session.getById(move.sessionId);
45
+ if (session?.channel_key === ch) {
46
+ const reply = await handleMove(null, services, message, move.sessionId, move.cell);
47
+ if (reply) await context.input.$reply(reply);
48
+ return;
49
+ }
50
+ }
51
+ await next();
52
+ return;
53
+ }
54
+
55
+ // 裸数字:按当前棋盘空格编号映射落子
56
+ const session = await services.session.getActiveForUser(ch, message.$sender.id);
57
+ if (!session || session.channel_key !== ch) {
58
+ await next();
59
+ return;
60
+ }
61
+ const map = buildFallbackMap(session.id, parseBoard(session.board));
62
+ const payloadFromDigit = resolveGameTextPayload(raw, map);
63
+ const move = payloadFromDigit ? parseTttPayload(payloadFromDigit) : null;
64
+ if (!move || move.sessionId !== session.id) {
65
+ await next();
66
+ return;
67
+ }
68
+ const reply = await handleMove(null, services, message, session.id, move.cell);
69
+ if (reply) await context.input.$reply(reply);
70
+ },
71
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zhin.js/plugin-tic-tac-toe",
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,29 +14,25 @@
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
- "keywords": [
23
- "zhin",
24
- "zhin.js",
25
- "bot",
26
- "plugin",
27
- "game",
28
- "tic-tac-toe"
29
- ],
30
25
  "license": "MIT",
31
- "peerDependencies": {
32
- "zhin.js": "4.1.0"
33
- },
34
26
  "dependencies": {
35
- "@zhin.js/game-shared": "1.0.0"
27
+ "@zhin.js/command": "1.0.1",
28
+ "@zhin.js/core": "1.3.5",
29
+ "@zhin.js/game-kit": "1.0.2",
30
+ "@zhin.js/middleware": "1.0.1",
31
+ "@zhin.js/plugin-runtime": "1.0.1"
36
32
  },
37
33
  "devDependencies": {
38
34
  "typescript": "^6.0.3",
39
- "zhin.js": "4.1.0"
35
+ "vitest": "^4.1.10"
40
36
  },
41
37
  "repository": {
42
38
  "type": "git",
@@ -47,8 +43,30 @@
47
43
  "access": "public",
48
44
  "registry": "https://registry.npmjs.org"
49
45
  },
46
+ "engines": {
47
+ "node": "^20.19.0 || >=22.12.0"
48
+ },
49
+ "zhin": {
50
+ "protocol": 1,
51
+ "type": "plugin",
52
+ "entry": "./plugin.ts",
53
+ "engine": "^1.0.0",
54
+ "runtime": "trusted",
55
+ "features": [
56
+ {
57
+ "package": "@zhin.js/command",
58
+ "api": "^1.0.0"
59
+ },
60
+ {
61
+ "package": "@zhin.js/middleware",
62
+ "api": "^1.0.0"
63
+ }
64
+ ],
65
+ "plugins": []
66
+ },
50
67
  "scripts": {
51
- "build": "tsc --build",
52
- "clean": "rimraf lib"
68
+ "build": "tsc",
69
+ "clean": "rimraf lib",
70
+ "test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/games/tic-tac-toe/tests"
53
71
  }
54
72
  }
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 { mountTttHostServices, mountTttMemoryServices } from './src/memory-db.js';
9
+ import { gameServicesToken } from './src/runtime-store.js';
10
+ import type { SessionServices } from './src/session-service.js';
11
+
12
+ /**
13
+ * Plugin Runtime:
14
+ * - Commands under `commands/` are authoritative (help + bot/join/…).
15
+ * - DB: prefer databaseHostToken; else in-memory SessionServices.
16
+ * - Choice middleware under `middlewares/` handles ttt grid / restart payloads (Sandbox action→text).
17
+ */
18
+ export default definePlugin({
19
+ name: 'tic-tac-toe',
20
+ metadata: {
21
+ displayName: 'Tic Tac Toe',
22
+ },
23
+ setup(context) {
24
+ let services: SessionServices;
25
+ if (context.resources.has(databaseHostToken)) {
26
+ const host = context.resources.use(databaseHostToken);
27
+ services = mountTttHostServices(host);
28
+ context.lifecycle.add(initGameRecordHost(host));
29
+ } else {
30
+ services = mountTttMemoryServices();
31
+ }
32
+ context.resources.provide(gameServicesToken, services);
33
+ const disposeHub = registerRuntimeGame({
34
+ id: 'ttt',
35
+ title: '井字棋',
36
+ icon: '♟️',
37
+ description: '三子连珠,群聊排队或人机对战',
38
+ commandPrefix: '/井字棋',
39
+ quickStart: '人机',
40
+ aliases: ['ttt'],
41
+ menus: [
42
+ { id: 'bot', label: '🤖 人机对战' },
43
+ { id: 'join', label: '👥 加入排队' },
44
+ { id: 'spectate', 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: 'ttt/abort-stale',
54
+ cron: DEFAULT_GAME_STALE_CRON,
55
+ description: 'Abort stale tic-tac-toe sessions',
56
+ async execute() {
57
+ if (!services.session.abortStale) return;
58
+ await services.session.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
+ }
package/src/board-view.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Message, SendContent } from 'zhin.js';
1
+ import type { Message, SendContent } from '@zhin.js/core';
2
2
  import {
3
3
  buildGridKeyboard,
4
4
  buildGridFallbackMap,
@@ -6,7 +6,7 @@ import {
6
6
  parseCellButtonId as parseButtonId,
7
7
  channelKey,
8
8
  type GridCell,
9
- } from '@zhin.js/game-shared';
9
+ } from '@zhin.js/game-kit';
10
10
  import {
11
11
  asciiBoard,
12
12
  cellLabel,
@@ -52,8 +52,9 @@ export function buildBoardInteractive(options: {
52
52
  terminal?: boolean;
53
53
  omitAsciiBoard?: boolean;
54
54
  highlight?: number[];
55
+ channelType?: string;
55
56
  }): SendContent {
56
- const { sessionId, board, statusLine, terminal, omitAsciiBoard, highlight } = options;
57
+ const { sessionId, board, statusLine, terminal, omitAsciiBoard, highlight, channelType } = options;
57
58
 
58
59
  return buildGridKeyboard({
59
60
  gamePrefix: TTT_PREFIX,
@@ -67,6 +68,10 @@ export function buildBoardInteractive(options: {
67
68
  renderAscii: renderTttAscii,
68
69
  highlight,
69
70
  fallbackHint: '落子:回复数字 1-9(仅空格)',
71
+ postChoices: terminal
72
+ ? [{ id: 'restart', label: '🔄 再来一局', style: 'primary' }]
73
+ : undefined,
74
+ channelType,
70
75
  });
71
76
  }
72
77
 
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, recordGameOutcome } from '@zhin.js/game-kit';
2
3
  import type { TttSessionRow } from './models.js';
3
4
  import { buildBoardInteractive } from './board-view.js';
4
5
  import {
@@ -32,7 +33,7 @@ export function isBotSession(session: TttSessionRow): boolean {
32
33
  }
33
34
 
34
35
  export async function sendOrEditBoard(
35
- plugin: Plugin,
36
+ plugin: Plugin | null,
36
37
  services: SessionServices,
37
38
  message: Message<any>,
38
39
  session: TttSessionRow,
@@ -49,8 +50,11 @@ export async function sendOrEditBoard(
49
50
  terminal,
50
51
  omitAsciiBoard: message.$adapter === 'qq',
51
52
  highlight,
53
+ channelType: message.$channel.type,
52
54
  });
53
55
 
56
+ if (!plugin) return plainTextFromSendContent(content);
57
+
54
58
  const adapter = plugin.root.inject(message.$adapter) as Adapter;
55
59
 
56
60
  if (session.board_message_id) {
@@ -75,6 +79,26 @@ export async function sendOrEditBoard(
75
79
  return msgId ?? '';
76
80
  }
77
81
 
82
+ export async function restartFromTerminal(
83
+ plugin: Plugin | null,
84
+ services: SessionServices,
85
+ message: Message<any>,
86
+ sessionId: string,
87
+ ): Promise<string | null> {
88
+ const session = await services.session.getById(sessionId);
89
+ if (!session) return '对局不存在。';
90
+ const mark = playerMark(message.$sender.id, {
91
+ playerX: session.player_x,
92
+ playerO: session.player_o,
93
+ });
94
+ if (!mark) return '你不是本局玩家。';
95
+ await services.session.updateSession(session.id, { status: 'aborted', winner: 0 });
96
+ const hint = await startBotGame(plugin, services, message);
97
+ if (hint.includes('已有')) return hint;
98
+ // text-only 模式(plugin===null)下 startBotGame 的唯一输出就是返回文本
99
+ return plugin ? null : hint;
100
+ }
101
+
78
102
  function turnCell(session: TttSessionRow): Cell {
79
103
  return session.turn === 1 ? X : O;
80
104
  }
@@ -84,7 +108,7 @@ function playerIdForTurn(session: TttSessionRow): string {
84
108
  }
85
109
 
86
110
  export async function handleMove(
87
- plugin: Plugin,
111
+ plugin: Plugin | null,
88
112
  services: SessionServices,
89
113
  message: Message<any>,
90
114
  sessionId: string,
@@ -132,49 +156,71 @@ export async function handleMove(
132
156
 
133
157
  const updated = (await services.session.getById(session.id))!;
134
158
 
159
+ if (win || draw) {
160
+ const humanId = isBotSession(updated)
161
+ ? (updated.player_x === BOT_ID ? updated.player_o : updated.player_x)
162
+ : message.$sender.id;
163
+ const humanName = isBotSession(updated)
164
+ ? (updated.player_x === BOT_ID ? updated.player_o_name : updated.player_x_name)
165
+ : String(message.$sender.name ?? message.$sender.id);
166
+ const humanMsg = {
167
+ ...message,
168
+ $sender: { ...message.$sender, id: humanId, name: humanName },
169
+ } as Message<any>;
170
+ if (win) {
171
+ const humanMark = playerMark(humanId, {
172
+ playerX: updated.player_x,
173
+ playerO: updated.player_o,
174
+ });
175
+ const humanWon = humanMark === win.winner;
176
+ void recordGameOutcome(humanMsg, 'ttt', humanWon ? 'won' : 'lost', humanWon ? 20 : 0);
177
+ } else {
178
+ void recordGameOutcome(humanMsg, 'ttt', 'draw');
179
+ }
180
+ }
181
+
135
182
  if (win) {
136
183
  const status = formatWinHeadline(updated, win.winner);
137
- await sendOrEditBoard(plugin, services, message, updated, status, true, win.line);
138
- return null;
184
+ const text = await sendOrEditBoard(plugin, services, message, updated, status, true, win.line);
185
+ // text-only 模式(plugin===null)下 sendOrEditBoard 的唯一输出就是返回文本
186
+ return plugin ? null : text;
139
187
  }
140
188
  if (draw) {
141
- await sendOrEditBoard(plugin, services, message, updated, '平局。', true);
142
- return null;
189
+ const text = await sendOrEditBoard(plugin, services, message, updated, '平局。', true);
190
+ return plugin ? null : text;
143
191
  }
144
192
 
145
193
  // 人机:玩家落子后立刻由服务端代下,避免先发「轮到机器人」再发终盘(QQ 被动消息多耗一次)
146
194
  if (isBotSession(updated) && playerIdForTurn(updated) === BOT_ID) {
147
- await runBotMove(plugin, services, message, updated);
148
- return null;
195
+ return runBotMove(plugin, services, message, updated);
149
196
  }
150
197
 
151
198
  const status = formatTurnStatus(updated, moveCount);
152
- await sendOrEditBoard(plugin, services, message, updated, status, false);
153
-
154
- return null;
199
+ const text = await sendOrEditBoard(plugin, services, message, updated, status, false);
200
+ return plugin ? null : text;
155
201
  }
156
202
 
157
203
  async function runBotMove(
158
- plugin: Plugin,
204
+ plugin: Plugin | null,
159
205
  services: SessionServices,
160
206
  message: Message<any>,
161
207
  session: TttSessionRow,
162
- ): Promise<void> {
208
+ ): Promise<string | null> {
163
209
  const board = parseBoard(session.board);
164
210
  const aiMark = session.player_o === BOT_ID ? O : X;
165
211
  const cell = bestMove(board, aiMark);
166
- if (cell < 0) return;
212
+ if (cell < 0) return null;
167
213
 
168
214
  const fakeMessage = {
169
215
  ...message,
170
216
  $sender: { ...message.$sender, id: BOT_ID, name: '机器人' },
171
217
  } as Message<any>;
172
218
 
173
- await handleMove(plugin, services, fakeMessage, session.id, cell);
219
+ return handleMove(plugin, services, fakeMessage, session.id, cell);
174
220
  }
175
221
 
176
222
  export async function startBotGame(
177
- plugin: Plugin,
223
+ plugin: Plugin | null,
178
224
  services: SessionServices,
179
225
  message: Message<any>,
180
226
  ): Promise<string> {
@@ -192,17 +238,20 @@ export async function startBotGame(
192
238
  });
193
239
 
194
240
  const status = `${formatRosterLine(session)} · 你先手 (✕)`;
195
- await sendOrEditBoard(plugin, services, message, session, status, false);
241
+ const boardText = await sendOrEditBoard(plugin, services, message, session, status, false);
242
+ if (!plugin) {
243
+ return `${boardText}\n\n开局成功!回复数字 1–9 落子。`;
244
+ }
196
245
  return '开局成功!点击棋盘或回复数字落子。';
197
246
  }
198
247
 
199
248
  export async function startPvpGame(
200
- plugin: Plugin,
249
+ plugin: Plugin | null,
201
250
  services: SessionServices,
202
251
  message: Message<any>,
203
252
  playerX: TttPlayerRef,
204
253
  playerO: TttPlayerRef,
205
- ): Promise<void> {
254
+ ): Promise<string | void> {
206
255
  const session = await services.session.createSession({
207
256
  message,
208
257
  playerX: playerX.id,
@@ -213,5 +262,6 @@ export async function startPvpGame(
213
262
  });
214
263
  const opener = formatPlayerWithMark(session.player_x, session.player_x_name, '✕');
215
264
  const status = `${formatRosterLine(session)} · 先手:${opener}`;
216
- await sendOrEditBoard(plugin, services, message, session, status, false);
265
+ const boardText = await sendOrEditBoard(plugin, services, message, session, status, false);
266
+ if (!plugin) return boardText;
217
267
  }
package/src/index.ts CHANGED
@@ -1,43 +1,9 @@
1
+ export { TTT_HELP } from './ttt-command.js';
2
+ export { gameServicesToken, resolveGameServices } from './runtime-store.js';
3
+ export { createInMemoryTttDb, mountTttMemoryServices } from './memory-db.js';
4
+
1
5
  /**
2
- * @zhin.js/plugin-tic-tac-toe — 跨平台井字棋
3
- *
4
- * ```yaml
5
- * plugins:
6
- * - "@zhin.js/plugin-tic-tac-toe"
7
- * database:
8
- * dialect: sqlite
9
- * storage: ./data/zhin.db
10
- * ```
6
+ * 已由 plugin.ts 接线:commands/ 命令、middlewares/ 文本与选项中间件、
7
+ * 游戏大厅注册(registerRuntimeGame)、过期会话 cron(scheduleHostToken)、
8
+ * host DatabaseHost(databaseHostToken)。仍未接:interactive 按钮回调。
11
9
  */
12
- import { Cron, formatCompact, usePlugin, type DatabaseFeature } from 'zhin.js';
13
- import { registerModels } from './models.js';
14
- import { createServices, resolveGameDatabase, type SessionServices } from './session-service.js';
15
- import { registerCommands, registerInteractive, registerTextFallback } from './commands.js';
16
- import { registerTttHub } from './hub-register.js';
17
-
18
- const plugin = usePlugin();
19
- const { logger, useContext, addCron } = plugin;
20
-
21
- registerModels(plugin);
22
-
23
- let services: SessionServices | null = null;
24
-
25
- useContext('database', (dbFeature: DatabaseFeature) => {
26
- services = createServices(resolveGameDatabase(dbFeature));
27
- logger.info(formatCompact({ 模块: '井字棋', 数据模型: '已就绪' }));
28
- });
29
-
30
- registerTttHub(() => services);
31
- registerCommands(plugin, () => services);
32
- registerInteractive(plugin, () => services);
33
- registerTextFallback(plugin, () => services);
34
-
35
- addCron(
36
- new Cron('0 */10 * * * *', async () => {
37
- if (!services) return;
38
- const n = await services.session.abortStale(30 * 60 * 1000);
39
- if (n > 0) logger.debug(formatCompact({ 井字棋: '清理超时局', count: n }));
40
- }),
41
- );
42
-
43
- logger.info(formatCompact({ 模块: '井字棋', 状态: '已加载' }));
@@ -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 TttDatabase, type SessionServices } from './session-service.js';
5
+
6
+ const TTT_TABLES = ['ttt_sessions', 'ttt_queue', 'ttt_moves', 'ttt_spectators'] as const;
7
+
8
+ /** In-memory ttt tables for Plugin Runtime slice-2 (no DatabaseFeature). */
9
+ export function createInMemoryTttDb(): TttDatabase {
10
+ return createInMemoryGameDb(TTT_TABLES) as unknown as TttDatabase;
11
+ }
12
+
13
+ export function mountTttMemoryServices(): SessionServices {
14
+ const services = createServices(createInMemoryTttDb());
15
+ return services;
16
+ }
17
+
18
+ export function mountTttHostServices(host: DatabaseHost): SessionServices {
19
+ defineHostTables(host);
20
+ const services = createServices(createHostGameDb(host, TTT_TABLES) as unknown as TttDatabase);
21
+ return services;
22
+ }
package/src/models.ts CHANGED
@@ -1,8 +1,8 @@
1
- import type { Models, Plugin } from 'zhin.js';
1
+ import type { Models } from '@zhin.js/core';
2
2
 
3
3
  export type TttSessionStatus = 'active' | 'won' | 'draw' | 'aborted';
4
4
 
5
- declare module 'zhin.js' {
5
+ declare module '@zhin.js/core' {
6
6
  interface Models {
7
7
  ttt_sessions: {
8
8
  id: string;
@@ -56,8 +56,11 @@ export type TttSpectatorRow = Models['ttt_spectators'];
56
56
  /** 井字棋插件注册的表名 */
57
57
  export type TttModelName = 'ttt_sessions' | 'ttt_queue' | 'ttt_moves' | 'ttt_spectators';
58
58
 
59
- export function registerModels(plugin: Plugin): void {
60
- plugin.defineModel('ttt_sessions', {
59
+
60
+ export function defineHostTables(
61
+ db: { define: (name: string, definition: Record<string, unknown>) => void },
62
+ ): void {
63
+ db.define('ttt_sessions', {
61
64
  id: { type: 'text', primary: true },
62
65
  adapter: { type: 'text', nullable: false },
63
66
  endpoint: { type: 'text', nullable: false },
@@ -77,16 +80,14 @@ export function registerModels(plugin: Plugin): void {
77
80
  updated_at: { type: 'integer', default: 0 },
78
81
  created_at: { type: 'integer', default: 0 },
79
82
  });
80
-
81
- plugin.defineModel('ttt_queue', {
83
+ db.define('ttt_queue', {
82
84
  id: { type: 'integer', primary: true },
83
85
  channel_key: { type: 'text', nullable: false },
84
86
  user_id: { type: 'text', nullable: false },
85
87
  user_name: { type: 'text', default: '' },
86
88
  joined_at: { type: 'integer', default: 0 },
87
89
  });
88
-
89
- plugin.defineModel('ttt_moves', {
90
+ db.define('ttt_moves', {
90
91
  id: { type: 'integer', primary: true },
91
92
  session_id: { type: 'text', nullable: false },
92
93
  player_id: { type: 'text', nullable: false },
@@ -94,8 +95,7 @@ export function registerModels(plugin: Plugin): void {
94
95
  move_index: { type: 'integer', nullable: false },
95
96
  created_at: { type: 'integer', default: 0 },
96
97
  });
97
-
98
- plugin.defineModel('ttt_spectators', {
98
+ db.define('ttt_spectators', {
99
99
  id: { type: 'integer', primary: true },
100
100
  session_id: { type: 'text', nullable: false },
101
101
  user_id: { type: 'text', nullable: false },
@@ -1,4 +1,4 @@
1
- import type { MessageSender } from 'zhin.js';
1
+ import type { MessageSender } from '@zhin.js/core';
2
2
  import type { TttSessionRow } from './models.js';
3
3
  import { X, O, type Cell, cellLabel } from './engine.js';
4
4
 
@@ -0,0 +1,9 @@
1
+ import { createToken } from '@zhin.js/plugin-runtime';
2
+ import type { SessionServices } from './session-service.js';
3
+
4
+ /** Owner-scoped service consumed by discovered commands and middleware. */
5
+ export const gameServicesToken = createToken<SessionServices>('zhin.game.tic-tac-toe.services');
6
+
7
+ export function resolveGameServices(context: { use<T>(token: typeof gameServicesToken): T }): SessionServices {
8
+ return context.use(gameServicesToken) as SessionServices;
9
+ }
@@ -1,5 +1,5 @@
1
- import type { Database, DatabaseFeature, Message, Models, RelatedModel } from 'zhin.js';
2
- import { channelKey } from '@zhin.js/game-shared';
1
+ import type { Database, Message, Models, RelatedModel } from '@zhin.js/core';
2
+ import { channelKey, boardMessageMatches, generateCompactId } from '@zhin.js/game-kit';
3
3
  import type { TttModelName, TttSessionRow } from './models.js';
4
4
 
5
5
  /** 井字棋服务使用的数据库实例(Models 经 models.ts 模块增强) */
@@ -75,7 +75,7 @@ export class QueueService {
75
75
  }
76
76
 
77
77
  export function sessionId(): string {
78
- return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
78
+ return generateCompactId('s');
79
79
  }
80
80
 
81
81
  export class SessionService {
@@ -103,14 +103,9 @@ export class SessionService {
103
103
  /** 根据棋盘消息 ID 查找进行中的局(QQ 等 action 带 sourceMessageId) */
104
104
  async getActiveByBoardMessageId(messageId: string): Promise<TttSessionRow | null> {
105
105
  if (!messageId) return null;
106
- const rows = await getModel(this.db, 'ttt_sessions').findAll({ status: 'active' });
106
+ const rows = await getModel(this.db, 'ttt_sessions').findAll({});
107
107
  for (const row of rows) {
108
- const stored = row.board_message_id;
109
- if (!stored) continue;
110
- if (stored === messageId) return row;
111
- if (stored.endsWith(`:${messageId}`)) return row;
112
- const tail = stored.split(':').pop();
113
- if (tail && messageId.endsWith(tail)) return row;
108
+ if (boardMessageMatches(row.board_message_id ?? '', messageId)) return row;
114
109
  }
115
110
  return null;
116
111
  }
@@ -200,6 +195,3 @@ export function createServices(db: TttDatabase): SessionServices {
200
195
  return { queue: new QueueService(db), session: new SessionService(db) };
201
196
  }
202
197
 
203
- export function resolveGameDatabase(feature: DatabaseFeature): TttDatabase {
204
- return feature.db;
205
- }