@zhin.js/plugin-tic-tac-toe 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.
Files changed (53) hide show
  1. package/README.md +36 -0
  2. package/lib/board-view.d.ts +24 -0
  3. package/lib/board-view.d.ts.map +1 -0
  4. package/lib/board-view.js +58 -0
  5. package/lib/board-view.js.map +1 -0
  6. package/lib/commands.d.ts +6 -0
  7. package/lib/commands.d.ts.map +1 -0
  8. package/lib/commands.js +111 -0
  9. package/lib/commands.js.map +1 -0
  10. package/lib/engine.d.ts +25 -0
  11. package/lib/engine.d.ts.map +1 -0
  12. package/lib/engine.js +117 -0
  13. package/lib/engine.js.map +1 -0
  14. package/lib/game-flow.d.ts +11 -0
  15. package/lib/game-flow.d.ts.map +1 -0
  16. package/lib/game-flow.js +143 -0
  17. package/lib/game-flow.js.map +1 -0
  18. package/lib/hub-register.d.ts +5 -0
  19. package/lib/hub-register.d.ts.map +1 -0
  20. package/lib/hub-register.js +30 -0
  21. package/lib/hub-register.js.map +1 -0
  22. package/lib/index.d.ts +2 -0
  23. package/lib/index.d.ts.map +1 -0
  24. package/lib/index.js +37 -0
  25. package/lib/index.js.map +1 -0
  26. package/lib/models.d.ts +55 -0
  27. package/lib/models.d.ts.map +1 -0
  28. package/lib/models.js +44 -0
  29. package/lib/models.js.map +1 -0
  30. package/lib/player-label.d.ts +16 -0
  31. package/lib/player-label.d.ts.map +1 -0
  32. package/lib/player-label.js +36 -0
  33. package/lib/player-label.js.map +1 -0
  34. package/lib/session-service.d.ts +50 -0
  35. package/lib/session-service.d.ts.map +1 -0
  36. package/lib/session-service.js +176 -0
  37. package/lib/session-service.js.map +1 -0
  38. package/lib/ttt-command.d.ts +5 -0
  39. package/lib/ttt-command.d.ts.map +1 -0
  40. package/lib/ttt-command.js +67 -0
  41. package/lib/ttt-command.js.map +1 -0
  42. package/package.json +54 -0
  43. package/plugin.yml +2 -0
  44. package/src/board-view.ts +93 -0
  45. package/src/commands.ts +129 -0
  46. package/src/engine.ts +131 -0
  47. package/src/game-flow.ts +217 -0
  48. package/src/hub-register.ts +31 -0
  49. package/src/index.ts +43 -0
  50. package/src/models.ts +104 -0
  51. package/src/player-label.ts +48 -0
  52. package/src/session-service.ts +205 -0
  53. package/src/ttt-command.ts +76 -0
@@ -0,0 +1,93 @@
1
+ import type { Message, SendContent } from 'zhin.js';
2
+ import {
3
+ buildGridKeyboard,
4
+ buildGridFallbackMap,
5
+ parseGridPayload,
6
+ parseCellButtonId as parseButtonId,
7
+ channelKey,
8
+ type GridCell,
9
+ } from '@zhin.js/game-shared';
10
+ import {
11
+ asciiBoard,
12
+ cellLabel,
13
+ type Board,
14
+ type Cell,
15
+ EMPTY,
16
+ X,
17
+ O,
18
+ } from './engine.js';
19
+
20
+ /** 游戏前缀(用于 payload) */
21
+ export const TTT_PREFIX = 'ttt';
22
+
23
+ export { channelKey };
24
+
25
+ /** 将井字棋棋盘转为通用 GridCell 数组 */
26
+ function boardToCells(board: Board, highlight?: number[]): GridCell<Cell>[] {
27
+ const highlightSet = new Set(highlight ?? []);
28
+ return board.map((cell, i) => ({
29
+ state: cell,
30
+ label: cell === EMPTY ? '·' : cellLabel(cell),
31
+ disabled: cell !== EMPTY,
32
+ highlight: highlightSet.has(i),
33
+ }));
34
+ }
35
+
36
+ /** 井字棋 ASCII 棋盘渲染器(适配 GridCell) */
37
+ function renderTttAscii(
38
+ cells: GridCell<Cell>[],
39
+ rows: number,
40
+ cols: number,
41
+ highlight?: number[],
42
+ ): string {
43
+ const board = cells.map((c) => c.state);
44
+ return asciiBoard(board as Board, highlight);
45
+ }
46
+
47
+ export function buildBoardInteractive(options: {
48
+ sessionId: string;
49
+ board: Board;
50
+ statusLine: string;
51
+ turnMark: Cell;
52
+ terminal?: boolean;
53
+ omitAsciiBoard?: boolean;
54
+ highlight?: number[];
55
+ }): SendContent {
56
+ const { sessionId, board, statusLine, terminal, omitAsciiBoard, highlight } = options;
57
+
58
+ return buildGridKeyboard({
59
+ gamePrefix: TTT_PREFIX,
60
+ sessionId,
61
+ rows: 3,
62
+ cols: 3,
63
+ cells: boardToCells(board, highlight),
64
+ statusLine,
65
+ terminal,
66
+ omitAsciiBoard,
67
+ renderAscii: renderTttAscii,
68
+ highlight,
69
+ fallbackHint: '落子:回复数字 1-9(仅空格)',
70
+ });
71
+ }
72
+
73
+ export function buildFallbackMap(sessionId: string, board: Board): Record<string, string> {
74
+ return buildGridFallbackMap(TTT_PREFIX, sessionId, boardToCells(board));
75
+ }
76
+
77
+ export function parseTttPayload(payload: string): { sessionId: string; cell: number } | null {
78
+ const result = parseGridPayload(payload, TTT_PREFIX);
79
+ if (!result) return null;
80
+ if (result.cell > 8) return null;
81
+ return { sessionId: result.sessionId, cell: result.cell };
82
+ }
83
+
84
+ /** QQ 等平台回调可能只带 button_id(如 c4),不含 ttt: 前缀 payload */
85
+ export function parseCellButtonId(value: string): number | null {
86
+ const cell = parseButtonId(value);
87
+ if (cell === null || cell > 8) return null;
88
+ return cell;
89
+ }
90
+
91
+ export function markName(mark: Cell): string {
92
+ return mark === X ? '✕' : '○';
93
+ }
@@ -0,0 +1,129 @@
1
+ import { Message, MessageCommand, getActionFromMessage, type Plugin } from 'zhin.js';
2
+ import { channelKey, normalizeTttAction, registerGameTextMiddleware } from '@zhin.js/game-shared';
3
+ import { buildFallbackMap } from './board-view.js';
4
+ import { parseBoard } from './engine.js';
5
+ import { handleMove } from './game-flow.js';
6
+ import { runTttCommand } from './ttt-command.js';
7
+ import type { SessionServices } from './session-service.js';
8
+
9
+ function actionPayload(message: Message<any>): string | undefined {
10
+ return getActionFromMessage(message)?.payload;
11
+ }
12
+
13
+ /** @deprecated 使用 game-shared parseChoicePayload;保留 ttt 专用解析 */
14
+ function parseTttPayload(payload: string): { sessionId: string; cell: number } | null {
15
+ const m = /^ttt:([^:]+):(\d)$/.exec(payload);
16
+ if (!m) return null;
17
+ return { sessionId: m[1]!, cell: Number(m[2]) };
18
+ }
19
+
20
+ function parseCellButtonId(id: string): number | null {
21
+ const m = /^c(\d)$/.exec(id);
22
+ if (!m) return null;
23
+ const cell = Number(m[1]);
24
+ return cell >= 0 && cell <= 8 ? cell : null;
25
+ }
26
+
27
+ async function resolveTttMove(
28
+ message: Message<any>,
29
+ services: SessionServices,
30
+ ): Promise<{ sessionId: string; cell: number } | null> {
31
+ const action = getActionFromMessage(message);
32
+ if (!action) return null;
33
+
34
+ const fromPayload = parseTttPayload(action.payload);
35
+ if (fromPayload) return fromPayload;
36
+
37
+ const cell =
38
+ parseCellButtonId(action.payload)
39
+ ?? parseCellButtonId(action.id ?? '');
40
+ if (cell == null) return null;
41
+
42
+ const ch = channelKey(message);
43
+ const session =
44
+ await services.session.getActiveForUser(ch, message.$sender.id)
45
+ ?? (action.sourceMessageId
46
+ ? await services.session.getActiveByBoardMessageId(action.sourceMessageId)
47
+ : null);
48
+ if (!session || session.channel_key !== ch) return null;
49
+ return { sessionId: session.id, cell };
50
+ }
51
+
52
+ function registerTttPattern(
53
+ plugin: Plugin,
54
+ pattern: string,
55
+ desc: string,
56
+ getServices: () => SessionServices | null,
57
+ ): void {
58
+ plugin.addCommand(
59
+ new MessageCommand(pattern)
60
+ .desc(desc)
61
+ .action(async (message, result) => {
62
+ const services = getServices();
63
+ if (!services) return '井字棋需要启用 database 配置。';
64
+ const raw = (result.params.action as string | undefined) ?? '';
65
+ const action = normalizeTttAction(raw);
66
+ return runTttCommand(plugin, services, message, action);
67
+ }),
68
+ );
69
+ }
70
+
71
+ export function registerCommands(
72
+ plugin: Plugin,
73
+ getServices: () => SessionServices | null,
74
+ ): void {
75
+ registerTttPattern(plugin, 'ttt [action:word]', '井字棋(ttt)', getServices);
76
+ registerTttPattern(plugin, '井字棋 [action:word]', '井字棋(中文)', getServices);
77
+ }
78
+
79
+ async function handleTttAction(
80
+ plugin: Plugin,
81
+ getServices: () => SessionServices | null,
82
+ message: Message<any>,
83
+ ): Promise<boolean> {
84
+ const services = getServices();
85
+ if (!services) return false;
86
+ const move = await resolveTttMove(message, services);
87
+ if (!move) return false;
88
+ const err = await handleMove(plugin, services, message, move.sessionId, move.cell);
89
+ if (err) await message.$reply?.(err);
90
+ return true;
91
+ }
92
+
93
+ export function registerInteractive(plugin: Plugin, getServices: () => SessionServices | null): void {
94
+ plugin.registerInteractiveHandler('ttt:', (message) => handleTttAction(plugin, getServices, message));
95
+ plugin.registerInteractiveHandler('c', (message) => handleTttAction(plugin, getServices, message));
96
+ }
97
+
98
+ export function registerTextFallback(plugin: Plugin, getServices: () => SessionServices | null): void {
99
+ registerGameTextMiddleware(plugin, async (message, next) => {
100
+ const services = getServices();
101
+ if (!services) return next();
102
+
103
+ const inboundAction = actionPayload(message);
104
+ if (inboundAction?.startsWith('ttt:')) return next();
105
+
106
+ const ch = channelKey(message);
107
+ const session = await services.session.getActiveForUser(ch, message.$sender.id);
108
+ if (!session) return next();
109
+
110
+ const raw = message.$raw?.trim() ?? '';
111
+ let cell: number | null = null;
112
+ const direct = parseTttPayload(raw.startsWith('ttt:') ? raw : '');
113
+ if (direct && direct.sessionId === session.id) {
114
+ cell = direct.cell;
115
+ } else {
116
+ const n = /^(\d)$/.exec(raw);
117
+ if (n) {
118
+ const map = buildFallbackMap(session.id, parseBoard(session.board));
119
+ const payload = map[n[1]!];
120
+ const p = payload ? parseTttPayload(payload) : null;
121
+ if (p?.sessionId === session.id) cell = p.cell;
122
+ }
123
+ }
124
+ if (cell == null) return next();
125
+
126
+ const err = await handleMove(plugin, services, message, session.id, cell);
127
+ if (err) await message.$reply?.(err);
128
+ }, 'ttt:text');
129
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,131 @@
1
+ export const EMPTY = 0;
2
+ export const X = 1;
3
+ export const O = 2;
4
+
5
+ export type Cell = typeof EMPTY | typeof X | typeof O;
6
+ export type Board = Cell[];
7
+
8
+ export type GameStatus = 'active' | 'won' | 'draw' | 'aborted';
9
+
10
+ const WIN_LINES = [
11
+ [0, 1, 2], [3, 4, 5], [6, 7, 8],
12
+ [0, 3, 6], [1, 4, 7], [2, 5, 8],
13
+ [0, 4, 8], [2, 4, 6],
14
+ ];
15
+
16
+ export function emptyBoard(): Board {
17
+ return Array(9).fill(EMPTY);
18
+ }
19
+
20
+ export function parseBoard(raw: string | number[] | null | undefined): Board {
21
+ if (Array.isArray(raw)) return raw.map((v) => Number(v) as Cell);
22
+ if (typeof raw === 'string') {
23
+ try {
24
+ const parsed = JSON.parse(raw) as number[];
25
+ if (Array.isArray(parsed) && parsed.length === 9) {
26
+ return parsed.map((v) => Number(v) as Cell);
27
+ }
28
+ } catch {
29
+ // fall through
30
+ }
31
+ }
32
+ return emptyBoard();
33
+ }
34
+
35
+ export function cellLabel(cell: Cell): string {
36
+ if (cell === X) return '✕';
37
+ if (cell === O) return '○';
38
+ return '·';
39
+ }
40
+
41
+ export function asciiBoard(board: Board, highlight?: number[]): string {
42
+ const mark = (i: number) => {
43
+ const base = cellLabel(board[i]!);
44
+ return highlight?.includes(i) ? `[${base}]` : ` ${base} `;
45
+ };
46
+ return [
47
+ `${mark(0)}|${mark(1)}|${mark(2)}`,
48
+ '-+-+-',
49
+ `${mark(3)}|${mark(4)}|${mark(5)}`,
50
+ '-+-+-',
51
+ `${mark(6)}|${mark(7)}|${mark(8)}`,
52
+ ].join('\n');
53
+ }
54
+
55
+ export function checkWinner(board: Board): { winner: Cell; line: number[] } | null {
56
+ for (const line of WIN_LINES) {
57
+ const [a, b, c] = line;
58
+ const v = board[a!]!;
59
+ if (v !== EMPTY && v === board[b!] && v === board[c!]) {
60
+ return { winner: v, line };
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ export function isDraw(board: Board): boolean {
67
+ return board.every((c) => c !== EMPTY) && !checkWinner(board);
68
+ }
69
+
70
+ export function validMove(board: Board, cell: number): boolean {
71
+ return Number.isInteger(cell) && cell >= 0 && cell < 9 && board[cell] === EMPTY;
72
+ }
73
+
74
+ export function applyMove(board: Board, cell: number, player: Cell): Board {
75
+ if (!validMove(board, cell)) throw new Error('invalid move');
76
+ const next = [...board] as Board;
77
+ next[cell] = player;
78
+ return next;
79
+ }
80
+
81
+ export function opponent(player: Cell): Cell {
82
+ return player === X ? O : X;
83
+ }
84
+
85
+ /** Minimax for tic-tac-toe (O = AI by default when aiPlays O) */
86
+ export function bestMove(board: Board, ai: Cell = O): number {
87
+ const human = opponent(ai);
88
+ let bestScore = -Infinity;
89
+ let move = -1;
90
+
91
+ for (let i = 0; i < 9; i++) {
92
+ if (board[i] !== EMPTY) continue;
93
+ const next = applyMove(board, i, ai);
94
+ const score = minimax(next, 0, false, ai, human);
95
+ if (score > bestScore) {
96
+ bestScore = score;
97
+ move = i;
98
+ }
99
+ }
100
+ return move;
101
+ }
102
+
103
+ function minimax(
104
+ board: Board,
105
+ depth: number,
106
+ isAi: boolean,
107
+ ai: Cell,
108
+ human: Cell,
109
+ ): number {
110
+ const win = checkWinner(board);
111
+ if (win?.winner === ai) return 10 - depth;
112
+ if (win?.winner === human) return depth - 10;
113
+ if (isDraw(board)) return 0;
114
+
115
+ const player = isAi ? ai : human;
116
+ let best = isAi ? -Infinity : Infinity;
117
+
118
+ for (let i = 0; i < 9; i++) {
119
+ if (board[i] !== EMPTY) continue;
120
+ const next = applyMove(board, i, player);
121
+ const score = minimax(next, depth + 1, !isAi, ai, human);
122
+ best = isAi ? Math.max(best, score) : Math.min(best, score);
123
+ }
124
+ return best;
125
+ }
126
+
127
+ export function playerMark(playerId: string, session: { playerX: string; playerO: string }): Cell | null {
128
+ if (playerId === session.playerX) return X;
129
+ if (playerId === session.playerO) return O;
130
+ return null;
131
+ }
@@ -0,0 +1,217 @@
1
+ import type { Adapter, Message, Plugin } from 'zhin.js';
2
+ import type { TttSessionRow } from './models.js';
3
+ import { buildBoardInteractive } from './board-view.js';
4
+ import {
5
+ applyMove,
6
+ bestMove,
7
+ checkWinner,
8
+ isDraw,
9
+ parseBoard,
10
+ playerMark,
11
+ validMove,
12
+ X,
13
+ O,
14
+ type Cell,
15
+ cellLabel,
16
+ } from './engine.js';
17
+ import {
18
+ BOT_ID,
19
+ formatPlayerWithMark,
20
+ formatRosterLine,
21
+ formatTurnStatus,
22
+ formatWinHeadline,
23
+ playerRefForMark,
24
+ senderDisplayName,
25
+ } from './player-label.js';
26
+ import type { SessionServices, TttPlayerRef } from './session-service.js';
27
+
28
+ export { BOT_ID };
29
+
30
+ export function isBotSession(session: TttSessionRow): boolean {
31
+ return session.player_o === BOT_ID || session.player_x === BOT_ID;
32
+ }
33
+
34
+ export async function sendOrEditBoard(
35
+ plugin: Plugin,
36
+ services: SessionServices,
37
+ message: Message<any>,
38
+ session: TttSessionRow,
39
+ statusLine: string,
40
+ terminal = false,
41
+ highlight?: number[],
42
+ ): Promise<string> {
43
+ const board = parseBoard(session.board);
44
+ const content = buildBoardInteractive({
45
+ sessionId: session.id,
46
+ board,
47
+ statusLine,
48
+ turnMark: session.turn as Cell,
49
+ terminal,
50
+ omitAsciiBoard: message.$adapter === 'qq',
51
+ highlight,
52
+ });
53
+
54
+ const adapter = plugin.root.inject(message.$adapter) as Adapter;
55
+
56
+ if (session.board_message_id) {
57
+ const msgId = await adapter.editMessage({
58
+ messageId: session.board_message_id,
59
+ context: String(message.$adapter),
60
+ endpoint: message.$endpoint,
61
+ id: message.$channel.id,
62
+ type: message.$channel.type,
63
+ content,
64
+ });
65
+ if (msgId !== session.board_message_id) {
66
+ await services.session.updateSession(session.id, { board_message_id: msgId });
67
+ }
68
+ return msgId;
69
+ }
70
+
71
+ const msgId = await message.$reply?.(content);
72
+ if (msgId) {
73
+ await services.session.updateSession(session.id, { board_message_id: msgId });
74
+ }
75
+ return msgId ?? '';
76
+ }
77
+
78
+ function turnCell(session: TttSessionRow): Cell {
79
+ return session.turn === 1 ? X : O;
80
+ }
81
+
82
+ function playerIdForTurn(session: TttSessionRow): string {
83
+ return session.turn === 1 ? session.player_x : session.player_o;
84
+ }
85
+
86
+ export async function handleMove(
87
+ plugin: Plugin,
88
+ services: SessionServices,
89
+ message: Message<any>,
90
+ sessionId: string,
91
+ cell: number,
92
+ ): Promise<string | null> {
93
+ const session = await services.session.getById(sessionId);
94
+ if (!session || session.status !== 'active') {
95
+ return '对局不存在或已结束。';
96
+ }
97
+ if (session.channel_key !== `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`) {
98
+ return '请在开局频道落子。';
99
+ }
100
+
101
+ const mark = playerMark(message.$sender.id, {
102
+ playerX: session.player_x,
103
+ playerO: session.player_o,
104
+ });
105
+ if (!mark) {
106
+ return '你不是本局玩家。';
107
+ }
108
+ if (mark !== turnCell(session)) {
109
+ const turn = turnCell(session);
110
+ const { id, name } = playerRefForMark(session, turn);
111
+ return `还没轮到你(当前轮到 ${formatPlayerWithMark(id, name, cellLabel(turn) as '✕' | '○')})。`;
112
+ }
113
+
114
+ const board = parseBoard(session.board);
115
+ if (!validMove(board, cell)) {
116
+ return '该位置不可落子。';
117
+ }
118
+
119
+ const nextBoard = applyMove(board, cell, mark);
120
+ const win = checkWinner(nextBoard);
121
+ const draw = !win && isDraw(nextBoard);
122
+ const moveCount = session.move_count + 1;
123
+
124
+ await services.session.recordMove(session.id, message.$sender.id, cell, moveCount);
125
+ await services.session.updateSession(session.id, {
126
+ board: JSON.stringify(nextBoard),
127
+ move_count: moveCount,
128
+ turn: session.turn === 1 ? 2 : 1,
129
+ status: win ? 'won' : draw ? 'draw' : 'active',
130
+ winner: win ? win.winner : 0,
131
+ });
132
+
133
+ const updated = (await services.session.getById(session.id))!;
134
+
135
+ if (win) {
136
+ const status = formatWinHeadline(updated, win.winner);
137
+ await sendOrEditBoard(plugin, services, message, updated, status, true, win.line);
138
+ return null;
139
+ }
140
+ if (draw) {
141
+ await sendOrEditBoard(plugin, services, message, updated, '平局。', true);
142
+ return null;
143
+ }
144
+
145
+ // 人机:玩家落子后立刻由服务端代下,避免先发「轮到机器人」再发终盘(QQ 被动消息多耗一次)
146
+ if (isBotSession(updated) && playerIdForTurn(updated) === BOT_ID) {
147
+ await runBotMove(plugin, services, message, updated);
148
+ return null;
149
+ }
150
+
151
+ const status = formatTurnStatus(updated, moveCount);
152
+ await sendOrEditBoard(plugin, services, message, updated, status, false);
153
+
154
+ return null;
155
+ }
156
+
157
+ async function runBotMove(
158
+ plugin: Plugin,
159
+ services: SessionServices,
160
+ message: Message<any>,
161
+ session: TttSessionRow,
162
+ ): Promise<void> {
163
+ const board = parseBoard(session.board);
164
+ const aiMark = session.player_o === BOT_ID ? O : X;
165
+ const cell = bestMove(board, aiMark);
166
+ if (cell < 0) return;
167
+
168
+ const fakeMessage = {
169
+ ...message,
170
+ $sender: { ...message.$sender, id: BOT_ID, name: '机器人' },
171
+ } as Message<any>;
172
+
173
+ await handleMove(plugin, services, fakeMessage, session.id, cell);
174
+ }
175
+
176
+ export async function startBotGame(
177
+ plugin: Plugin,
178
+ services: SessionServices,
179
+ message: Message<any>,
180
+ ): Promise<string> {
181
+ const ch = `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
182
+ const active = await services.session.getActiveByChannel(ch);
183
+ if (active) return '当前频道已有进行中的对局。';
184
+
185
+ const session = await services.session.createSession({
186
+ message,
187
+ playerX: message.$sender.id,
188
+ playerO: BOT_ID,
189
+ playerXName: senderDisplayName(message.$sender),
190
+ playerOName: '机器人',
191
+ boardJson: JSON.stringify([0, 0, 0, 0, 0, 0, 0, 0, 0]),
192
+ });
193
+
194
+ const status = `${formatRosterLine(session)} · 你先手 (✕)`;
195
+ await sendOrEditBoard(plugin, services, message, session, status, false);
196
+ return '开局成功!点击棋盘或回复数字落子。';
197
+ }
198
+
199
+ export async function startPvpGame(
200
+ plugin: Plugin,
201
+ services: SessionServices,
202
+ message: Message<any>,
203
+ playerX: TttPlayerRef,
204
+ playerO: TttPlayerRef,
205
+ ): Promise<void> {
206
+ const session = await services.session.createSession({
207
+ message,
208
+ playerX: playerX.id,
209
+ playerO: playerO.id,
210
+ playerXName: playerX.displayName,
211
+ playerOName: playerO.displayName,
212
+ boardJson: JSON.stringify([0, 0, 0, 0, 0, 0, 0, 0, 0]),
213
+ });
214
+ const opener = formatPlayerWithMark(session.player_x, session.player_x_name, '✕');
215
+ const status = `${formatRosterLine(session)} · 先手:${opener}`;
216
+ await sendOrEditBoard(plugin, services, message, session, status, false);
217
+ }
@@ -0,0 +1,31 @@
1
+ import { getPlugin } from 'zhin.js';
2
+ import { ensureGameHubService } from '@zhin.js/game-shared';
3
+ import { runTttCommand, TTT_HELP } from './ttt-command.js';
4
+ import type { SessionServices } from './session-service.js';
5
+
6
+ export function registerTttHub(getServices: () => SessionServices | null): () => void {
7
+ const plugin = getPlugin();
8
+ ensureGameHubService(plugin);
9
+ return plugin.registerGame({
10
+ id: 'ttt',
11
+ title: '井字棋',
12
+ icon: '♟️',
13
+ description: '三子连珠,群聊排队或人机对战',
14
+ commandPrefix: '井字棋',
15
+ quickStart: '人机',
16
+ aliases: ['ttt'],
17
+ menus: [
18
+ { id: 'bot', label: '🤖 人机对战', style: 'primary' },
19
+ { id: 'join', label: '👥 加入排队', groupOnly: true },
20
+ { id: 'spectate', label: '👀 观战', groupOnly: true },
21
+ { id: 'help', label: '📖 玩法说明' },
22
+ ],
23
+ runAction: async (actionId, ctx) => {
24
+ const services = getServices();
25
+ if (!services) return '井字棋需要启用 database 配置。';
26
+ return runTttCommand(ctx.plugin, services, ctx.message, actionId);
27
+ },
28
+ });
29
+ }
30
+
31
+ export { TTT_HELP };
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
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
+ * ```
11
+ */
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({ 模块: '井字棋', 状态: '已加载' }));