@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.
- package/README.md +36 -0
- package/lib/board-view.d.ts +24 -0
- package/lib/board-view.d.ts.map +1 -0
- package/lib/board-view.js +58 -0
- package/lib/board-view.js.map +1 -0
- package/lib/commands.d.ts +6 -0
- package/lib/commands.d.ts.map +1 -0
- package/lib/commands.js +111 -0
- package/lib/commands.js.map +1 -0
- package/lib/engine.d.ts +25 -0
- package/lib/engine.d.ts.map +1 -0
- package/lib/engine.js +117 -0
- package/lib/engine.js.map +1 -0
- package/lib/game-flow.d.ts +11 -0
- package/lib/game-flow.d.ts.map +1 -0
- package/lib/game-flow.js +143 -0
- package/lib/game-flow.js.map +1 -0
- package/lib/hub-register.d.ts +5 -0
- package/lib/hub-register.d.ts.map +1 -0
- package/lib/hub-register.js +30 -0
- package/lib/hub-register.js.map +1 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +37 -0
- package/lib/index.js.map +1 -0
- package/lib/models.d.ts +55 -0
- package/lib/models.d.ts.map +1 -0
- package/lib/models.js +44 -0
- package/lib/models.js.map +1 -0
- package/lib/player-label.d.ts +16 -0
- package/lib/player-label.d.ts.map +1 -0
- package/lib/player-label.js +36 -0
- package/lib/player-label.js.map +1 -0
- package/lib/session-service.d.ts +50 -0
- package/lib/session-service.d.ts.map +1 -0
- package/lib/session-service.js +176 -0
- package/lib/session-service.js.map +1 -0
- package/lib/ttt-command.d.ts +5 -0
- package/lib/ttt-command.d.ts.map +1 -0
- package/lib/ttt-command.js +67 -0
- package/lib/ttt-command.js.map +1 -0
- package/package.json +54 -0
- package/plugin.yml +2 -0
- package/src/board-view.ts +93 -0
- package/src/commands.ts +129 -0
- package/src/engine.ts +131 -0
- package/src/game-flow.ts +217 -0
- package/src/hub-register.ts +31 -0
- package/src/index.ts +43 -0
- package/src/models.ts +104 -0
- package/src/player-label.ts +48 -0
- package/src/session-service.ts +205 -0
- package/src/ttt-command.ts +76 -0
package/src/models.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Models, Plugin } from 'zhin.js';
|
|
2
|
+
|
|
3
|
+
export type TttSessionStatus = 'active' | 'won' | 'draw' | 'aborted';
|
|
4
|
+
|
|
5
|
+
declare module 'zhin.js' {
|
|
6
|
+
interface Models {
|
|
7
|
+
ttt_sessions: {
|
|
8
|
+
id: string;
|
|
9
|
+
adapter: string;
|
|
10
|
+
endpoint: string;
|
|
11
|
+
channel_type: string;
|
|
12
|
+
channel_id: string;
|
|
13
|
+
channel_key: string;
|
|
14
|
+
player_x: string;
|
|
15
|
+
player_o: string;
|
|
16
|
+
player_x_name: string;
|
|
17
|
+
player_o_name: string;
|
|
18
|
+
board: string;
|
|
19
|
+
turn: number;
|
|
20
|
+
status: TttSessionStatus;
|
|
21
|
+
winner: number;
|
|
22
|
+
board_message_id: string;
|
|
23
|
+
move_count: number;
|
|
24
|
+
updated_at: number;
|
|
25
|
+
created_at: number;
|
|
26
|
+
};
|
|
27
|
+
ttt_queue: {
|
|
28
|
+
id: number;
|
|
29
|
+
channel_key: string;
|
|
30
|
+
user_id: string;
|
|
31
|
+
user_name: string;
|
|
32
|
+
joined_at: number;
|
|
33
|
+
};
|
|
34
|
+
ttt_moves: {
|
|
35
|
+
id: number;
|
|
36
|
+
session_id: string;
|
|
37
|
+
player_id: string;
|
|
38
|
+
cell: number;
|
|
39
|
+
move_index: number;
|
|
40
|
+
created_at: number;
|
|
41
|
+
};
|
|
42
|
+
ttt_spectators: {
|
|
43
|
+
id: number;
|
|
44
|
+
session_id: string;
|
|
45
|
+
user_id: string;
|
|
46
|
+
joined_at: number;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type TttSessionRow = Models['ttt_sessions'];
|
|
52
|
+
export type TttQueueRow = Models['ttt_queue'];
|
|
53
|
+
export type TttMoveRow = Models['ttt_moves'];
|
|
54
|
+
export type TttSpectatorRow = Models['ttt_spectators'];
|
|
55
|
+
|
|
56
|
+
/** 井字棋插件注册的表名 */
|
|
57
|
+
export type TttModelName = 'ttt_sessions' | 'ttt_queue' | 'ttt_moves' | 'ttt_spectators';
|
|
58
|
+
|
|
59
|
+
export function registerModels(plugin: Plugin): void {
|
|
60
|
+
plugin.defineModel('ttt_sessions', {
|
|
61
|
+
id: { type: 'text', primary: true },
|
|
62
|
+
adapter: { type: 'text', nullable: false },
|
|
63
|
+
endpoint: { type: 'text', nullable: false },
|
|
64
|
+
channel_type: { type: 'text', nullable: false },
|
|
65
|
+
channel_id: { type: 'text', nullable: false },
|
|
66
|
+
channel_key: { type: 'text', nullable: false },
|
|
67
|
+
player_x: { type: 'text', nullable: false },
|
|
68
|
+
player_o: { type: 'text', nullable: false },
|
|
69
|
+
player_x_name: { type: 'text', default: '' },
|
|
70
|
+
player_o_name: { type: 'text', default: '' },
|
|
71
|
+
board: { type: 'text', default: '[]' },
|
|
72
|
+
turn: { type: 'integer', default: 1 },
|
|
73
|
+
status: { type: 'text', default: 'active' },
|
|
74
|
+
winner: { type: 'integer', default: 0 },
|
|
75
|
+
board_message_id: { type: 'text', default: '' },
|
|
76
|
+
move_count: { type: 'integer', default: 0 },
|
|
77
|
+
updated_at: { type: 'integer', default: 0 },
|
|
78
|
+
created_at: { type: 'integer', default: 0 },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
plugin.defineModel('ttt_queue', {
|
|
82
|
+
id: { type: 'integer', primary: true },
|
|
83
|
+
channel_key: { type: 'text', nullable: false },
|
|
84
|
+
user_id: { type: 'text', nullable: false },
|
|
85
|
+
user_name: { type: 'text', default: '' },
|
|
86
|
+
joined_at: { type: 'integer', default: 0 },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
plugin.defineModel('ttt_moves', {
|
|
90
|
+
id: { type: 'integer', primary: true },
|
|
91
|
+
session_id: { type: 'text', nullable: false },
|
|
92
|
+
player_id: { type: 'text', nullable: false },
|
|
93
|
+
cell: { type: 'integer', nullable: false },
|
|
94
|
+
move_index: { type: 'integer', nullable: false },
|
|
95
|
+
created_at: { type: 'integer', default: 0 },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
plugin.defineModel('ttt_spectators', {
|
|
99
|
+
id: { type: 'integer', primary: true },
|
|
100
|
+
session_id: { type: 'text', nullable: false },
|
|
101
|
+
user_id: { type: 'text', nullable: false },
|
|
102
|
+
joined_at: { type: 'integer', default: 0 },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { MessageSender } from 'zhin.js';
|
|
2
|
+
import type { TttSessionRow } from './models.js';
|
|
3
|
+
import { X, O, type Cell, cellLabel } from './engine.js';
|
|
4
|
+
|
|
5
|
+
export const BOT_ID = '__ttt_bot__';
|
|
6
|
+
|
|
7
|
+
export function senderDisplayName(sender: Pick<MessageSender, 'id' | 'name'>): string {
|
|
8
|
+
const name = sender.name?.trim();
|
|
9
|
+
return name || sender.id;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function playerDisplayName(playerId: string, storedName?: string): string {
|
|
13
|
+
if (playerId === BOT_ID) return '机器人';
|
|
14
|
+
const name = storedName?.trim();
|
|
15
|
+
return name || playerId;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatPlayerWithMark(
|
|
19
|
+
playerId: string,
|
|
20
|
+
storedName: string | undefined,
|
|
21
|
+
mark: '✕' | '○',
|
|
22
|
+
): string {
|
|
23
|
+
return `${playerDisplayName(playerId, storedName)} (${mark})`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatRosterLine(session: TttSessionRow): string {
|
|
27
|
+
return `${formatPlayerWithMark(session.player_x, session.player_x_name, '✕')} vs ${formatPlayerWithMark(session.player_o, session.player_o_name, '○')}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function playerRefForMark(session: TttSessionRow, mark: Cell): { id: string; name?: string } {
|
|
31
|
+
if (mark === X) return { id: session.player_x, name: session.player_x_name };
|
|
32
|
+
return { id: session.player_o, name: session.player_o_name };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatTurnStatus(session: TttSessionRow, moveCount: number): string {
|
|
36
|
+
const mark = session.turn === 1 ? X : O;
|
|
37
|
+
const { id, name } = playerRefForMark(session, mark);
|
|
38
|
+
return `第 ${moveCount} 手 · 轮到 ${formatPlayerWithMark(id, name, cellLabel(mark) as '✕' | '○')}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function formatWinHeadline(session: TttSessionRow, winner: Cell): string {
|
|
42
|
+
const { id, name } = playerRefForMark(session, winner);
|
|
43
|
+
return `🎉 ${formatPlayerWithMark(id, name, cellLabel(winner) as '✕' | '○')} 获胜!`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function formatWinStatus(session: TttSessionRow, winner: Cell, boardAscii: string): string {
|
|
47
|
+
return `${formatWinHeadline(session, winner)}\n${boardAscii}`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type { Database, DatabaseFeature, Message, Models, RelatedModel } from 'zhin.js';
|
|
2
|
+
import { channelKey } from '@zhin.js/game-shared';
|
|
3
|
+
import type { TttModelName, TttSessionRow } from './models.js';
|
|
4
|
+
|
|
5
|
+
/** 井字棋服务使用的数据库实例(Models 经 models.ts 模块增强) */
|
|
6
|
+
export type TttDatabase = Database<unknown, Models, string>;
|
|
7
|
+
|
|
8
|
+
type TttModel<K extends TttModelName> = RelatedModel<unknown, Models, K>;
|
|
9
|
+
|
|
10
|
+
export type TttPlayerRef = { id: string; displayName: string };
|
|
11
|
+
|
|
12
|
+
function getModel<K extends TttModelName>(db: TttDatabase, name: K): TttModel<K> {
|
|
13
|
+
const model = db.models.get(name);
|
|
14
|
+
if (!model) {
|
|
15
|
+
throw new Error(`Model ${name} is not registered`);
|
|
16
|
+
}
|
|
17
|
+
return model as TttModel<K>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class QueueService {
|
|
21
|
+
constructor(private readonly db: TttDatabase) {}
|
|
22
|
+
|
|
23
|
+
async join(
|
|
24
|
+
channel: string,
|
|
25
|
+
userId: string,
|
|
26
|
+
displayName?: string,
|
|
27
|
+
): Promise<{ queued: boolean; position: number }> {
|
|
28
|
+
const q = getModel(this.db, 'ttt_queue');
|
|
29
|
+
const existing = await q.findAll({ channel_key: channel, user_id: userId });
|
|
30
|
+
const all = await q.findAll({ channel_key: channel });
|
|
31
|
+
all.sort((a, b) => a.joined_at - b.joined_at);
|
|
32
|
+
if (existing.length > 0) {
|
|
33
|
+
return { queued: true, position: all.findIndex((r) => r.user_id === userId) + 1 };
|
|
34
|
+
}
|
|
35
|
+
const name = displayName?.trim() || '';
|
|
36
|
+
await q.create({ channel_key: channel, user_id: userId, user_name: name, joined_at: Date.now() });
|
|
37
|
+
const after = await q.findAll({ channel_key: channel });
|
|
38
|
+
after.sort((a, b) => a.joined_at - b.joined_at);
|
|
39
|
+
return { queued: true, position: after.length };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async leave(channel: string, userId: string): Promise<boolean> {
|
|
43
|
+
const q = getModel(this.db, 'ttt_queue');
|
|
44
|
+
const rows = await q.findAll({ channel_key: channel, user_id: userId });
|
|
45
|
+
if (rows.length === 0) return false;
|
|
46
|
+
await q.deleteWhere({ channel_key: channel, user_id: userId });
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async list(channel: string): Promise<string[]> {
|
|
51
|
+
const rows = await getModel(this.db, 'ttt_queue').findAll({ channel_key: channel });
|
|
52
|
+
return rows.map((r) => r.user_id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async tryMatch(channel: string): Promise<[TttPlayerRef, TttPlayerRef] | null> {
|
|
56
|
+
const q = getModel(this.db, 'ttt_queue');
|
|
57
|
+
const rows = await q.findAll({ channel_key: channel });
|
|
58
|
+
rows.sort((a, b) => a.joined_at - b.joined_at);
|
|
59
|
+
if (rows.length < 2) return null;
|
|
60
|
+
await q.deleteWhere({ channel_key: channel });
|
|
61
|
+
const toRef = (userId: string, userName: string) => ({
|
|
62
|
+
id: userId,
|
|
63
|
+
displayName: userName.trim() || userId,
|
|
64
|
+
});
|
|
65
|
+
return [
|
|
66
|
+
toRef(rows[0]!.user_id, rows[0]!.user_name),
|
|
67
|
+
toRef(rows[1]!.user_id, rows[1]!.user_name),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async count(channel: string): Promise<number> {
|
|
72
|
+
const rows = await getModel(this.db, 'ttt_queue').findAll({ channel_key: channel });
|
|
73
|
+
return rows.length;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function sessionId(): string {
|
|
78
|
+
return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class SessionService {
|
|
82
|
+
constructor(private readonly db: TttDatabase) {}
|
|
83
|
+
|
|
84
|
+
async getActiveByChannel(channel: string): Promise<TttSessionRow | null> {
|
|
85
|
+
const rows = await getModel(this.db, 'ttt_sessions').findAll({
|
|
86
|
+
channel_key: channel,
|
|
87
|
+
status: 'active',
|
|
88
|
+
});
|
|
89
|
+
return rows[0] ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async getById(id: string): Promise<TttSessionRow | null> {
|
|
93
|
+
return getModel(this.db, 'ttt_sessions').findOne({ id });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getActiveForUser(channel: string, userId: string): Promise<TttSessionRow | null> {
|
|
97
|
+
const row = await this.getActiveByChannel(channel);
|
|
98
|
+
if (!row) return null;
|
|
99
|
+
if (row.player_x === userId || row.player_o === userId) return row;
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 根据棋盘消息 ID 查找进行中的局(QQ 等 action 带 sourceMessageId) */
|
|
104
|
+
async getActiveByBoardMessageId(messageId: string): Promise<TttSessionRow | null> {
|
|
105
|
+
if (!messageId) return null;
|
|
106
|
+
const rows = await getModel(this.db, 'ttt_sessions').findAll({ status: 'active' });
|
|
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;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async createSession(input: {
|
|
119
|
+
message: Message<any>;
|
|
120
|
+
playerX: string;
|
|
121
|
+
playerO: string;
|
|
122
|
+
playerXName?: string;
|
|
123
|
+
playerOName?: string;
|
|
124
|
+
boardJson: string;
|
|
125
|
+
}): Promise<TttSessionRow> {
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
const id = sessionId();
|
|
128
|
+
const ch = channelKey(input.message);
|
|
129
|
+
const row: TttSessionRow = {
|
|
130
|
+
id,
|
|
131
|
+
adapter: String(input.message.$adapter),
|
|
132
|
+
endpoint: input.message.$endpoint,
|
|
133
|
+
channel_type: input.message.$channel.type,
|
|
134
|
+
channel_id: input.message.$channel.id,
|
|
135
|
+
channel_key: ch,
|
|
136
|
+
player_x: input.playerX,
|
|
137
|
+
player_o: input.playerO,
|
|
138
|
+
player_x_name: input.playerXName?.trim() || input.playerX,
|
|
139
|
+
player_o_name: input.playerOName?.trim() || input.playerO,
|
|
140
|
+
board: input.boardJson,
|
|
141
|
+
turn: 1,
|
|
142
|
+
status: 'active',
|
|
143
|
+
winner: 0,
|
|
144
|
+
board_message_id: '',
|
|
145
|
+
move_count: 0,
|
|
146
|
+
updated_at: now,
|
|
147
|
+
created_at: now,
|
|
148
|
+
};
|
|
149
|
+
await getModel(this.db, 'ttt_sessions').create(row);
|
|
150
|
+
return row;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async updateSession(id: string, patch: Partial<TttSessionRow>): Promise<void> {
|
|
154
|
+
await getModel(this.db, 'ttt_sessions').updateWhere(
|
|
155
|
+
{ id },
|
|
156
|
+
{ ...patch, updated_at: Date.now() },
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async recordMove(sessionId: string, playerId: string, cell: number, moveIndex: number): Promise<void> {
|
|
161
|
+
await getModel(this.db, 'ttt_moves').create({
|
|
162
|
+
session_id: sessionId,
|
|
163
|
+
player_id: playerId,
|
|
164
|
+
cell,
|
|
165
|
+
move_index: moveIndex,
|
|
166
|
+
created_at: Date.now(),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async addSpectator(sessionId: string, userId: string): Promise<void> {
|
|
171
|
+
const sp = getModel(this.db, 'ttt_spectators');
|
|
172
|
+
const existing = await sp.findOne({ session_id: sessionId, user_id: userId });
|
|
173
|
+
if (existing) return;
|
|
174
|
+
await sp.create({ session_id: sessionId, user_id: userId, joined_at: Date.now() });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async listSpectators(sessionId: string): Promise<string[]> {
|
|
178
|
+
const rows = await getModel(this.db, 'ttt_spectators').findAll({ session_id: sessionId });
|
|
179
|
+
return rows.map((r) => r.user_id);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async abortStale(idleMs: number): Promise<number> {
|
|
183
|
+
const cutoff = Date.now() - idleMs;
|
|
184
|
+
const sessions = getModel(this.db, 'ttt_sessions');
|
|
185
|
+
const rows = await sessions.findAll({ status: 'active' });
|
|
186
|
+
let n = 0;
|
|
187
|
+
for (const row of rows) {
|
|
188
|
+
if (row.updated_at < cutoff) {
|
|
189
|
+
await sessions.updateWhere({ id: row.id }, { status: 'aborted', updated_at: Date.now() });
|
|
190
|
+
n++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return n;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export type SessionServices = { queue: QueueService; session: SessionService };
|
|
198
|
+
|
|
199
|
+
export function createServices(db: TttDatabase): SessionServices {
|
|
200
|
+
return { queue: new QueueService(db), session: new SessionService(db) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function resolveGameDatabase(feature: DatabaseFeature): TttDatabase {
|
|
204
|
+
return feature.db;
|
|
205
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Message, Plugin } from 'zhin.js';
|
|
2
|
+
import { channelKey } from '@zhin.js/game-shared';
|
|
3
|
+
import { formatPlayerWithMark, formatRosterLine } from './player-label.js';
|
|
4
|
+
import { startBotGame, startPvpGame } from './game-flow.js';
|
|
5
|
+
import type { SessionServices } from './session-service.js';
|
|
6
|
+
|
|
7
|
+
export const TTT_HELP = [
|
|
8
|
+
'井字棋',
|
|
9
|
+
'井字棋 / ttt — 本帮助与频道状态',
|
|
10
|
+
'井字棋 人机 — 人机对战(私聊或单人)',
|
|
11
|
+
'井字棋 排队 — 群聊排队(满 2 人自动开局)',
|
|
12
|
+
'井字棋 离开 — 离开排队',
|
|
13
|
+
'井字棋 认输 — 结束当前局',
|
|
14
|
+
'井字棋 观战 — 订阅观战推送',
|
|
15
|
+
].join('\n');
|
|
16
|
+
|
|
17
|
+
export async function runTttCommand(
|
|
18
|
+
plugin: Plugin,
|
|
19
|
+
services: SessionServices,
|
|
20
|
+
message: Message<any>,
|
|
21
|
+
action: string,
|
|
22
|
+
): Promise<string | undefined> {
|
|
23
|
+
const ch = channelKey(message);
|
|
24
|
+
const userId = message.$sender.id;
|
|
25
|
+
|
|
26
|
+
if (!action || action === 'help') {
|
|
27
|
+
const active = await services.session.getActiveByChannel(ch);
|
|
28
|
+
const q = await services.queue.count(ch);
|
|
29
|
+
const lines = [TTT_HELP, ''];
|
|
30
|
+
if (active) lines.push(`进行中:${formatRosterLine(active)}`);
|
|
31
|
+
if (q > 0) lines.push(`排队:${q} 人`);
|
|
32
|
+
if (!active && q === 0) lines.push('暂无对局,发送「井字棋 人机」或从游戏大厅进入。');
|
|
33
|
+
return lines.join('\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (action === 'join') {
|
|
37
|
+
if (message.$channel.type === 'private') {
|
|
38
|
+
return '私聊请使用「井字棋 人机」。';
|
|
39
|
+
}
|
|
40
|
+
const inGame = await services.session.getActiveForUser(ch, userId);
|
|
41
|
+
if (inGame) return '你已在进行中的对局里。';
|
|
42
|
+
const { position } = await services.queue.join(ch, userId, message.$sender.name);
|
|
43
|
+
const pair = await services.queue.tryMatch(ch);
|
|
44
|
+
if (pair) {
|
|
45
|
+
const [px, po] = pair;
|
|
46
|
+
await startPvpGame(plugin, services, message, px, po);
|
|
47
|
+
return `匹配成功!${formatPlayerWithMark(px.id, px.displayName, '✕')} vs ${formatPlayerWithMark(po.id, po.displayName, '○')}`;
|
|
48
|
+
}
|
|
49
|
+
return `已加入排队(第 ${position} 位),凑满 2 人自动开局。`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (action === 'leave') {
|
|
53
|
+
const ok = await services.queue.leave(ch, userId);
|
|
54
|
+
return ok ? '已离开排队。' : '你不在排队中。';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (action === 'bot') {
|
|
58
|
+
return startBotGame(plugin, services, message);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (action === 'quit') {
|
|
62
|
+
const row = await services.session.getActiveForUser(ch, userId);
|
|
63
|
+
if (!row) return '你没有进行中的对局。';
|
|
64
|
+
await services.session.updateSession(row.id, { status: 'aborted', winner: 0 });
|
|
65
|
+
return '你已认输,对局结束。';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (action === 'spectate') {
|
|
69
|
+
const active = await services.session.getActiveByChannel(ch);
|
|
70
|
+
if (!active) return '当前频道没有进行中的对局。';
|
|
71
|
+
await services.session.addSpectator(active.id, userId);
|
|
72
|
+
return `已订阅观战(局 ${active.id})。每步会在频道更新棋盘。`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return `未知子命令:${action}\n\n${TTT_HELP}`;
|
|
76
|
+
}
|