@zhin.js/plugin-tic-tac-toe 1.0.3 → 1.0.5

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.
@@ -1,4 +1,4 @@
1
- import { channelKey } from '@zhin.js/game-kit';
1
+ import { channelKey, } from '@zhin.js/game-kit';
2
2
  import { formatPlayerWithMark, formatRosterLine } from './player-label.js';
3
3
  import { startBotGame, startPvpGame } from './game-flow.js';
4
4
  export const TTT_HELP = [
@@ -10,7 +10,7 @@ export const TTT_HELP = [
10
10
  '井字棋 认输 — 结束当前局',
11
11
  '井字棋 观战 — 订阅观战推送',
12
12
  ].join('\n');
13
- export async function runTttCommand(plugin, services, message, action) {
13
+ export async function runTttCommand(services, message, action) {
14
14
  const ch = channelKey(message);
15
15
  const userId = message.$sender.id;
16
16
  if (!action || action === 'help') {
@@ -36,9 +36,13 @@ export async function runTttCommand(plugin, services, message, action) {
36
36
  const pair = await services.queue.tryMatch(ch);
37
37
  if (pair) {
38
38
  const [px, po] = pair;
39
- const board = await startPvpGame(plugin, services, message, px, po);
39
+ const board = await startPvpGame(services, message, px, po);
40
40
  const matchLine = `匹配成功!${formatPlayerWithMark(px.id, px.displayName, '✕')} vs ${formatPlayerWithMark(po.id, po.displayName, '○')}`;
41
- return board ? `${matchLine}\n\n${board}` : matchLine;
41
+ return board
42
+ ? Array.isArray(board)
43
+ ? [matchLine, '\n\n', ...board]
44
+ : [matchLine, '\n\n', board]
45
+ : matchLine;
42
46
  }
43
47
  return `已加入排队(第 ${position} 位),凑满 2 人自动开局。`;
44
48
  }
@@ -47,7 +51,7 @@ export async function runTttCommand(plugin, services, message, action) {
47
51
  return ok ? '已离开排队。' : '你不在排队中。';
48
52
  }
49
53
  if (action === 'bot') {
50
- return startBotGame(plugin, services, message);
54
+ return startBotGame(services, message);
51
55
  }
52
56
  if (action === 'quit') {
53
57
  const row = await services.session.getActiveForUser(ch, userId);
@@ -65,8 +69,4 @@ export async function runTttCommand(plugin, services, message, action) {
65
69
  }
66
70
  return `未知子命令:${action}\n\n${TTT_HELP}`;
67
71
  }
68
- /** Plugin Runtime / smoke: text-only, no Adapter.editMessage. */
69
- export async function runTttCommandText(services, message, action) {
70
- return (await runTttCommand(null, services, message, action)) ?? '';
71
- }
72
72
  //# sourceMappingURL=ttt-command.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ttt-command.js","sourceRoot":"","sources":["../src/ttt-command.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ttt-command.js","sourceRoot":"","sources":["../src/ttt-command.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { defineGameCommandAliasMiddleware, isTttAction, messageFromCommandInput, normalizeTttAction, } from '@zhin.js/game-kit';
3
+ import { TTT_HELP, runTttCommand } from "../lib/ttt-command.js";
4
+ import { resolveGameServices } from "../lib/runtime-store.js";
5
+ export default defineGameCommandAliasMiddleware({
6
+ aliases: ['井字棋', 'ttt'],
7
+ async run(action, input, context) {
8
+ const normalized = normalizeTttAction(String(action ?? ''));
9
+ if (normalized === 'help')
10
+ return TTT_HELP;
11
+ // action 无法识别:放行给后续中间件,避免劫持普通聊天
12
+ if (!isTttAction(normalized))
13
+ return null;
14
+ const services = resolveGameServices(context);
15
+ const message = messageFromCommandInput(input);
16
+ return runTttCommand(services, message, normalized);
17
+ },
18
+ });
@@ -1,18 +1,21 @@
1
1
  import {
2
2
  defineGameCommandAliasMiddleware,
3
+ isTttAction,
3
4
  messageFromCommandInput,
4
5
  normalizeTttAction,
5
6
  } from '@zhin.js/game-kit';
6
- import { TTT_HELP, runTttCommandText } from '../src/ttt-command.js';
7
+ import { TTT_HELP, runTttCommand } from '../src/ttt-command.js';
7
8
  import { resolveGameServices } from '../src/runtime-store.js';
8
9
 
9
10
  export default defineGameCommandAliasMiddleware({
10
11
  aliases: ['井字棋', 'ttt'],
11
12
  async run(action, input, context) {
12
13
  const normalized = normalizeTttAction(String(action ?? ''));
13
- if (!normalized || normalized === 'help') return TTT_HELP;
14
+ if (normalized === 'help') return TTT_HELP;
15
+ // action 无法识别:放行给后续中间件,避免劫持普通聊天
16
+ if (!isTttAction(normalized)) return null;
14
17
  const services = resolveGameServices(context);
15
18
  const message = messageFromCommandInput(input);
16
- return runTttCommandText(services, message, normalized);
19
+ return runTttCommand(services, message, normalized);
17
20
  },
18
21
  });
@@ -0,0 +1,66 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { defineMiddleware } from '@zhin.js/middleware';
3
+ import { channelKey, messageFromCommandInput, parseChoicePayload, resolveGameTextPayload, } from '@zhin.js/game-kit';
4
+ import { resolveGameServices } from "../lib/runtime-store.js";
5
+ import { buildFallbackMap, TTT_PREFIX, parseTttPayload } from "../lib/board-view.js";
6
+ import { parseBoard } from "../lib/engine.js";
7
+ import { handleMove, restartFromTerminal } from "../lib/game-flow.js";
8
+ /**
9
+ * 文本入口:按钮 payload(`ttt:session:0-8` 或 `ttt:session:restart`)
10
+ * 与裸数字 fallback(按空格 1-9 编号落子,对应视图『落子:回复数字 1-9(仅空格)』)。
11
+ */
12
+ export default defineMiddleware({
13
+ target: 'inbound',
14
+ async handle(context, next) {
15
+ const raw = context.input.content?.trim() ?? '';
16
+ if (!raw) {
17
+ await next();
18
+ return;
19
+ }
20
+ const services = resolveGameServices(context);
21
+ const message = messageFromCommandInput(context.input);
22
+ const ch = channelKey(message);
23
+ // 直接 payload(QQ 指令预填 / Sandbox action→text)
24
+ const payload = resolveGameTextPayload(raw);
25
+ if (payload?.startsWith(`${TTT_PREFIX}:`)) {
26
+ const restart = parseChoicePayload(payload, TTT_PREFIX);
27
+ if (restart?.choiceId === 'restart') {
28
+ const session = await services.session.getById(restart.sessionId);
29
+ if (session?.channel_key === ch) {
30
+ const reply = await restartFromTerminal(services, message, restart.sessionId);
31
+ if (reply)
32
+ await context.input.$reply(reply);
33
+ return;
34
+ }
35
+ }
36
+ const move = parseTttPayload(payload);
37
+ if (move) {
38
+ const session = await services.session.getById(move.sessionId);
39
+ if (session?.channel_key === ch) {
40
+ const reply = await handleMove(services, message, move.sessionId, move.cell);
41
+ if (reply)
42
+ await context.input.$reply(reply);
43
+ return;
44
+ }
45
+ }
46
+ await next();
47
+ return;
48
+ }
49
+ // 裸数字:按当前棋盘空格编号映射落子
50
+ const session = await services.session.getActiveForUser(ch, message.$sender.id);
51
+ if (!session || session.channel_key !== ch) {
52
+ await next();
53
+ return;
54
+ }
55
+ const map = buildFallbackMap(session.id, parseBoard(session.board));
56
+ const payloadFromDigit = resolveGameTextPayload(raw, map);
57
+ const move = payloadFromDigit ? parseTttPayload(payloadFromDigit) : null;
58
+ if (!move || move.sessionId !== session.id) {
59
+ await next();
60
+ return;
61
+ }
62
+ const reply = await handleMove(services, message, session.id, move.cell);
63
+ if (reply)
64
+ await context.input.$reply(reply);
65
+ },
66
+ });
@@ -34,7 +34,7 @@ export default defineMiddleware<Message>({
34
34
  if (restart?.choiceId === 'restart') {
35
35
  const session = await services.session.getById(restart.sessionId);
36
36
  if (session?.channel_key === ch) {
37
- const reply = await restartFromTerminal(null, services, message, restart.sessionId);
37
+ const reply = await restartFromTerminal(services, message, restart.sessionId);
38
38
  if (reply) await context.input.$reply(reply);
39
39
  return;
40
40
  }
@@ -43,7 +43,7 @@ export default defineMiddleware<Message>({
43
43
  if (move) {
44
44
  const session = await services.session.getById(move.sessionId);
45
45
  if (session?.channel_key === ch) {
46
- const reply = await handleMove(null, services, message, move.sessionId, move.cell);
46
+ const reply = await handleMove(services, message, move.sessionId, move.cell);
47
47
  if (reply) await context.input.$reply(reply);
48
48
  return;
49
49
  }
@@ -65,7 +65,7 @@ export default defineMiddleware<Message>({
65
65
  await next();
66
66
  return;
67
67
  }
68
- const reply = await handleMove(null, services, message, session.id, move.cell);
68
+ const reply = await handleMove(services, message, session.id, move.cell);
69
69
  if (reply) await context.input.$reply(reply);
70
70
  },
71
71
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/plugin-tic-tac-toe",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Zhin.js 跨平台井字棋 (Plugin Runtime)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -14,7 +14,7 @@
14
14
  "./package.json": "./package.json"
15
15
  },
16
16
  "files": [
17
- "plugin.ts",
17
+ "plugin.js",
18
18
  "schema.json",
19
19
  "commands",
20
20
  "middlewares",
@@ -24,11 +24,11 @@
24
24
  ],
25
25
  "license": "MIT",
26
26
  "dependencies": {
27
- "@zhin.js/command": "1.0.2",
28
- "@zhin.js/core": "1.4.0",
29
- "@zhin.js/game-kit": "2.0.0",
30
- "@zhin.js/middleware": "1.0.2",
31
- "@zhin.js/plugin-runtime": "1.1.0"
27
+ "@zhin.js/command": "1.0.4",
28
+ "@zhin.js/core": "1.4.2",
29
+ "@zhin.js/game-kit": "2.0.2",
30
+ "@zhin.js/middleware": "1.0.4",
31
+ "@zhin.js/plugin-runtime": "1.1.1"
32
32
  },
33
33
  "devDependencies": {
34
34
  "typescript": "^6.0.3",
@@ -49,7 +49,7 @@
49
49
  "zhin": {
50
50
  "protocol": 1,
51
51
  "type": "plugin",
52
- "entry": "./plugin.ts",
52
+ "entry": "./plugin.js",
53
53
  "engine": "^1.0.0",
54
54
  "runtime": "trusted",
55
55
  "features": [
package/plugin.js ADDED
@@ -0,0 +1,84 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { defineGamePlugin, gameEvents, plainTextFromSendContent, } from '@zhin.js/game-kit';
3
+ import { outboundHostToken } from '@zhin.js/plugin-runtime';
4
+ import { defineHostTables } from "./lib/models.js";
5
+ import { gameServicesToken } from "./lib/runtime-store.js";
6
+ import { renderBoard } from "./lib/game-flow.js";
7
+ import { formatTurnStatus, formatWinHeadline, } from "./lib/player-label.js";
8
+ import { createServices, } from "./lib/session-service.js";
9
+ /**
10
+ * Plugin Runtime:
11
+ * - Commands under `commands/` are authoritative (help + bot/join/…).
12
+ * - DB: prefer databaseHostToken; else in-memory SessionServices.
13
+ * - Choice middleware under `middlewares/` handles ttt grid / restart payloads (Sandbox action→text).
14
+ */
15
+ export default defineGamePlugin({
16
+ name: 'tic-tac-toe',
17
+ metadata: {
18
+ displayName: 'Tic Tac Toe',
19
+ },
20
+ game: {
21
+ id: 'ttt',
22
+ title: '井字棋',
23
+ icon: '♟️',
24
+ description: '三子连珠,群聊排队或人机对战',
25
+ commandPrefix: '/井字棋',
26
+ quickStart: '人机',
27
+ aliases: ['ttt'],
28
+ menus: [
29
+ { id: 'bot', label: '🤖 人机对战' },
30
+ { id: 'join', label: '👥 加入排队' },
31
+ { id: 'spectate', label: '👀 观战' },
32
+ { id: 'help', label: '📖 玩法说明' },
33
+ ],
34
+ },
35
+ tables: ['ttt_sessions', 'ttt_queue', 'ttt_moves', 'ttt_spectators'],
36
+ servicesToken: gameServicesToken,
37
+ defineHostTables,
38
+ createServices: (database) => createServices(database),
39
+ session: (services) => services.session,
40
+ setup(context, services) {
41
+ if (!context.resources.has(outboundHostToken))
42
+ return;
43
+ const outbound = context.resources.use(outboundHostToken);
44
+ const notify = async (raw, terminal) => {
45
+ const session = raw;
46
+ if (!session.adapter || !session.endpoint)
47
+ return;
48
+ const spectators = await services.session.listSpectators(session.id);
49
+ if (spectators.length === 0)
50
+ return;
51
+ const status = session.status === 'draw'
52
+ ? '平局。'
53
+ : terminal && (session.winner === 1 || session.winner === 2)
54
+ ? formatWinHeadline(session, session.winner)
55
+ : formatTurnStatus(session, session.move_count);
56
+ const content = plainTextFromSendContent(renderBoard({
57
+ $adapter: session.adapter,
58
+ $endpoint: session.endpoint,
59
+ $channel: { type: 'private', id: '' },
60
+ $sender: { id: '' },
61
+ }, session, status, terminal));
62
+ await Promise.all(spectators.map((userId) => outbound.send({
63
+ adapter: session.adapter,
64
+ endpointId: session.endpoint,
65
+ channelType: 'private',
66
+ channelId: userId,
67
+ content,
68
+ })));
69
+ };
70
+ const disposeTurn = gameEvents.on('turn:change', async (event) => {
71
+ if (event.gameId === 'ttt' && event.session.status === 'active') {
72
+ await notify(event.session, false);
73
+ }
74
+ });
75
+ const disposeEnd = gameEvents.on('game:end', async (event) => {
76
+ if (event.gameId === 'ttt')
77
+ await notify(event.session, true);
78
+ });
79
+ return () => {
80
+ disposeEnd();
81
+ disposeTurn();
82
+ };
83
+ },
84
+ });
package/src/board-view.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Message, SendContent } from '@zhin.js/core';
1
+ import type { SendContent } from '@zhin.js/core';
2
2
  import {
3
3
  buildGridKeyboard,
4
4
  buildGridFallbackMap,
package/src/game-flow.ts CHANGED
@@ -1,5 +1,4 @@
1
- import type { Adapter, Message, Plugin } from '@zhin.js/core';
2
- import { plainTextFromSendContent, recordGameOutcome } from '@zhin.js/game-kit';
1
+ import type { GameMessageLike, GameReply } from '@zhin.js/game-kit';
3
2
  import type { TttSessionRow } from './models.js';
4
3
  import { buildBoardInteractive } from './board-view.js';
5
4
  import {
@@ -32,15 +31,14 @@ export function isBotSession(session: TttSessionRow): boolean {
32
31
  return session.player_o === BOT_ID || session.player_x === BOT_ID;
33
32
  }
34
33
 
35
- export async function sendOrEditBoard(
36
- plugin: Plugin | null,
37
- services: SessionServices,
38
- message: Message<any>,
34
+ /** Render the canonical board reply; delivery belongs to the Runtime Host. */
35
+ export function renderBoard(
36
+ message: GameMessageLike,
39
37
  session: TttSessionRow,
40
38
  statusLine: string,
41
39
  terminal = false,
42
40
  highlight?: number[],
43
- ): Promise<string> {
41
+ ): GameReply {
44
42
  const board = parseBoard(session.board);
45
43
  const content = buildBoardInteractive({
46
44
  sessionId: session.id,
@@ -53,50 +51,37 @@ export async function sendOrEditBoard(
53
51
  channelType: message.$channel.type,
54
52
  });
55
53
 
56
- if (!plugin) return plainTextFromSendContent(content);
57
-
58
- const adapter = plugin.root.inject(message.$adapter) as Adapter;
59
-
60
- if (session.board_message_id) {
61
- const msgId = await adapter.editMessage({
62
- messageId: session.board_message_id,
63
- context: String(message.$adapter),
64
- endpoint: message.$endpoint,
65
- id: message.$channel.id,
66
- type: message.$channel.type,
67
- content,
68
- });
69
- if (msgId !== session.board_message_id) {
70
- await services.session.updateSession(session.id, { board_message_id: msgId });
71
- }
72
- return msgId;
73
- }
74
-
75
- const msgId = await message.$reply?.(content);
76
- if (msgId) {
77
- await services.session.updateSession(session.id, { board_message_id: msgId });
78
- }
79
- return msgId ?? '';
54
+ return content;
80
55
  }
81
56
 
82
57
  export async function restartFromTerminal(
83
- plugin: Plugin | null,
84
58
  services: SessionServices,
85
- message: Message<any>,
59
+ message: GameMessageLike,
86
60
  sessionId: string,
87
- ): Promise<string | null> {
61
+ ): Promise<GameReply | null> {
88
62
  const session = await services.session.getById(sessionId);
89
63
  if (!session) return '对局不存在。';
64
+ if (session.status === 'active') return '对局尚未结束,无法重开。';
90
65
  const mark = playerMark(message.$sender.id, {
91
66
  playerX: session.player_x,
92
67
  playerO: session.player_o,
93
68
  });
94
69
  if (!mark) return '你不是本局玩家。';
95
70
  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;
71
+ // PvP 局重开仍是 PvP(原班人马),不静默降级为人机
72
+ if (!isBotSession(session)) {
73
+ const board = await startPvpGame(
74
+ services,
75
+ message,
76
+ { id: session.player_x, displayName: session.player_x_name },
77
+ { id: session.player_o, displayName: session.player_o_name },
78
+ );
79
+ if (typeof board === 'string' && board.includes('已有')) return board;
80
+ return board;
81
+ }
82
+ const hint = await startBotGame(services, message);
83
+ if (typeof hint === 'string' && hint.includes('已有')) return hint;
84
+ return hint;
100
85
  }
101
86
 
102
87
  function turnCell(session: TttSessionRow): Cell {
@@ -108,12 +93,11 @@ function playerIdForTurn(session: TttSessionRow): string {
108
93
  }
109
94
 
110
95
  export async function handleMove(
111
- plugin: Plugin | null,
112
96
  services: SessionServices,
113
- message: Message<any>,
97
+ message: GameMessageLike,
114
98
  sessionId: string,
115
99
  cell: number,
116
- ): Promise<string | null> {
100
+ ): Promise<GameReply | null> {
117
101
  const session = await services.session.getById(sessionId);
118
102
  if (!session || session.status !== 'active') {
119
103
  return '对局不存在或已结束。';
@@ -156,56 +140,28 @@ export async function handleMove(
156
140
 
157
141
  const updated = (await services.session.getById(session.id))!;
158
142
 
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
-
182
143
  if (win) {
183
144
  const status = formatWinHeadline(updated, win.winner);
184
- const text = await sendOrEditBoard(plugin, services, message, updated, status, true, win.line);
185
- // text-only 模式(plugin===null)下 sendOrEditBoard 的唯一输出就是返回文本
186
- return plugin ? null : text;
145
+ return renderBoard(message, updated, status, true, win.line);
187
146
  }
188
147
  if (draw) {
189
- const text = await sendOrEditBoard(plugin, services, message, updated, '平局。', true);
190
- return plugin ? null : text;
148
+ return renderBoard(message, updated, '平局。', true);
191
149
  }
192
150
 
193
151
  // 人机:玩家落子后立刻由服务端代下,避免先发「轮到机器人」再发终盘(QQ 被动消息多耗一次)
194
152
  if (isBotSession(updated) && playerIdForTurn(updated) === BOT_ID) {
195
- return runBotMove(plugin, services, message, updated);
153
+ return runBotMove(services, message, updated);
196
154
  }
197
155
 
198
156
  const status = formatTurnStatus(updated, moveCount);
199
- const text = await sendOrEditBoard(plugin, services, message, updated, status, false);
200
- return plugin ? null : text;
157
+ return renderBoard(message, updated, status, false);
201
158
  }
202
159
 
203
160
  async function runBotMove(
204
- plugin: Plugin | null,
205
161
  services: SessionServices,
206
- message: Message<any>,
162
+ message: GameMessageLike,
207
163
  session: TttSessionRow,
208
- ): Promise<string | null> {
164
+ ): Promise<GameReply | null> {
209
165
  const board = parseBoard(session.board);
210
166
  const aiMark = session.player_o === BOT_ID ? O : X;
211
167
  const cell = bestMove(board, aiMark);
@@ -214,16 +170,15 @@ async function runBotMove(
214
170
  const fakeMessage = {
215
171
  ...message,
216
172
  $sender: { ...message.$sender, id: BOT_ID, name: '机器人' },
217
- } as Message<any>;
173
+ } as GameMessageLike;
218
174
 
219
- return handleMove(plugin, services, fakeMessage, session.id, cell);
175
+ return handleMove(services, fakeMessage, session.id, cell);
220
176
  }
221
177
 
222
178
  export async function startBotGame(
223
- plugin: Plugin | null,
224
179
  services: SessionServices,
225
- message: Message<any>,
226
- ): Promise<string> {
180
+ message: GameMessageLike,
181
+ ): Promise<GameReply> {
227
182
  const ch = `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
228
183
  const active = await services.session.getActiveByChannel(ch);
229
184
  if (active) return '当前频道已有进行中的对局。';
@@ -238,20 +193,21 @@ export async function startBotGame(
238
193
  });
239
194
 
240
195
  const status = `${formatRosterLine(session)} · 你先手 (✕)`;
241
- const boardText = await sendOrEditBoard(plugin, services, message, session, status, false);
242
- if (!plugin) {
243
- return `${boardText}\n\n开局成功!回复数字 1–9 落子。`;
244
- }
245
- return '开局成功!点击棋盘或回复数字落子。';
196
+ const board = renderBoard(message, session, status, false);
197
+ const success = '\n\n开局成功!点击空格或回复数字 1–9 落子。';
198
+ return Array.isArray(board) ? [...board, success] : [board, success];
246
199
  }
247
200
 
248
201
  export async function startPvpGame(
249
- plugin: Plugin | null,
250
202
  services: SessionServices,
251
- message: Message<any>,
203
+ message: GameMessageLike,
252
204
  playerX: TttPlayerRef,
253
205
  playerO: TttPlayerRef,
254
- ): Promise<string | void> {
206
+ ): Promise<GameReply> {
207
+ const ch = `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
208
+ const active = await services.session.getActiveByChannel(ch);
209
+ if (active) return '当前频道已有进行中的对局。';
210
+
255
211
  const session = await services.session.createSession({
256
212
  message,
257
213
  playerX: playerX.id,
@@ -262,6 +218,5 @@ export async function startPvpGame(
262
218
  });
263
219
  const opener = formatPlayerWithMark(session.player_x, session.player_x_name, '✕');
264
220
  const status = `${formatRosterLine(session)} · 先手:${opener}`;
265
- const boardText = await sendOrEditBoard(plugin, services, message, session, status, false);
266
- if (!plugin) return boardText;
221
+ return renderBoard(message, session, status, false);
267
222
  }
package/src/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  export { TTT_HELP } from './ttt-command.js';
2
2
  export { gameServicesToken, resolveGameServices } from './runtime-store.js';
3
- export { createInMemoryTttDb, mountTttMemoryServices } from './memory-db.js';
4
3
 
5
4
  /**
6
5
  * 已由 plugin.ts 接线:commands/ 命令、middlewares/ 文本与选项中间件、
package/src/models.ts CHANGED
@@ -19,7 +19,6 @@ declare module '@zhin.js/core' {
19
19
  turn: number;
20
20
  status: TttSessionStatus;
21
21
  winner: number;
22
- board_message_id: string;
23
22
  move_count: number;
24
23
  updated_at: number;
25
24
  created_at: number;
@@ -75,7 +74,6 @@ export function defineHostTables(
75
74
  turn: { type: 'integer', default: 1 },
76
75
  status: { type: 'text', default: 'active' },
77
76
  winner: { type: 'integer', default: 0 },
78
- board_message_id: { type: 'text', default: '' },
79
77
  move_count: { type: 'integer', default: 0 },
80
78
  updated_at: { type: 'integer', default: 0 },
81
79
  created_at: { type: 'integer', default: 0 },