@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,6 +1,13 @@
1
- import type { Database, Message, Models, RelatedModel } from '@zhin.js/core';
2
- import { channelKey, boardMessageMatches, generateCompactId } from '@zhin.js/game-kit';
1
+ import type { Database, Models, RelatedModel } from '@zhin.js/core';
2
+ import {
3
+ BaseSessionService,
4
+ channelKey,
5
+ generateCompactId,
6
+ type GameMessageLike,
7
+ type GameSessionDatabase,
8
+ } from '@zhin.js/game-kit';
3
9
  import type { TttModelName, TttSessionRow } from './models.js';
10
+ import { BOT_ID } from './player-label.js';
4
11
 
5
12
  /** 井字棋服务使用的数据库实例(Models 经 models.ts 模块增强) */
6
13
  export type TttDatabase = Database<unknown, Models, string>;
@@ -9,6 +16,9 @@ type TttModel<K extends TttModelName> = RelatedModel<unknown, Models, K>;
9
16
 
10
17
  export type TttPlayerRef = { id: string; displayName: string };
11
18
 
19
+ /** 排队行过期时间(超时未匹配的排队视为失效) */
20
+ export const QUEUE_TTL_MS = 10 * 60 * 1000;
21
+
12
22
  function getModel<K extends TttModelName>(db: TttDatabase, name: K): TttModel<K> {
13
23
  const model = db.models.get(name);
14
24
  if (!model) {
@@ -20,12 +30,25 @@ function getModel<K extends TttModelName>(db: TttDatabase, name: K): TttModel<K>
20
30
  export class QueueService {
21
31
  constructor(private readonly db: TttDatabase) {}
22
32
 
33
+ /** 清掉频道内过期排队行(ttt_queue 无 TTL,靠 join 时顺带清理) */
34
+ private async pruneExpired(channel: string): Promise<void> {
35
+ const q = getModel(this.db, 'ttt_queue');
36
+ const cutoff = Date.now() - QUEUE_TTL_MS;
37
+ const rows = await q.findAll({ channel_key: channel });
38
+ for (const row of rows) {
39
+ if (row.joined_at < cutoff) {
40
+ await q.deleteWhere({ channel_key: channel, user_id: row.user_id });
41
+ }
42
+ }
43
+ }
44
+
23
45
  async join(
24
46
  channel: string,
25
47
  userId: string,
26
48
  displayName?: string,
27
49
  ): Promise<{ queued: boolean; position: number }> {
28
50
  const q = getModel(this.db, 'ttt_queue');
51
+ await this.pruneExpired(channel);
29
52
  const existing = await q.findAll({ channel_key: channel, user_id: userId });
30
53
  const all = await q.findAll({ channel_key: channel });
31
54
  all.sort((a, b) => a.joined_at - b.joined_at);
@@ -57,7 +80,9 @@ export class QueueService {
57
80
  const rows = await q.findAll({ channel_key: channel });
58
81
  rows.sort((a, b) => a.joined_at - b.joined_at);
59
82
  if (rows.length < 2) return null;
60
- await q.deleteWhere({ channel_key: channel });
83
+ // 只删除匹配到的两人,保留队列中其余的等待者
84
+ await q.deleteWhere({ channel_key: channel, user_id: rows[0]!.user_id });
85
+ await q.deleteWhere({ channel_key: channel, user_id: rows[1]!.user_id });
61
86
  const toRef = (userId: string, userName: string) => ({
62
87
  id: userId,
63
88
  displayName: userName.trim() || userId,
@@ -78,40 +103,45 @@ export function sessionId(): string {
78
103
  return generateCompactId('s');
79
104
  }
80
105
 
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',
106
+ export class SessionService extends BaseSessionService<TttSessionRow> {
107
+ constructor(private readonly db: TttDatabase) {
108
+ super(db as unknown as GameSessionDatabase<TttSessionRow>, {
109
+ gameId: 'ttt',
110
+ table: 'ttt_sessions',
111
+ userFields: ['player_x', 'player_o'],
112
+ projectOutcomes: (session) => {
113
+ if (session.status !== 'won' && session.status !== 'draw') return [];
114
+ return [
115
+ {
116
+ id: session.player_x,
117
+ name: session.player_x_name,
118
+ mark: 1,
119
+ },
120
+ {
121
+ id: session.player_o,
122
+ name: session.player_o_name,
123
+ mark: 2,
124
+ },
125
+ ]
126
+ .filter((player) => player.id !== BOT_ID)
127
+ .map((player) => ({
128
+ userId: player.id,
129
+ userName: player.name,
130
+ result: session.status === 'draw'
131
+ ? 'draw' as const
132
+ : session.winner === player.mark
133
+ ? 'won' as const
134
+ : 'lost' as const,
135
+ score: session.status === 'won' && session.winner === player.mark
136
+ ? 20
137
+ : undefined,
138
+ }));
139
+ },
88
140
  });
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({});
107
- for (const row of rows) {
108
- if (boardMessageMatches(row.board_message_id ?? '', messageId)) return row;
109
- }
110
- return null;
111
141
  }
112
142
 
113
143
  async createSession(input: {
114
- message: Message<any>;
144
+ message: GameMessageLike;
115
145
  playerX: string;
116
146
  playerO: string;
117
147
  playerXName?: string;
@@ -136,20 +166,11 @@ export class SessionService {
136
166
  turn: 1,
137
167
  status: 'active',
138
168
  winner: 0,
139
- board_message_id: '',
140
169
  move_count: 0,
141
170
  updated_at: now,
142
171
  created_at: now,
143
172
  };
144
- await getModel(this.db, 'ttt_sessions').create(row);
145
- return row;
146
- }
147
-
148
- async updateSession(id: string, patch: Partial<TttSessionRow>): Promise<void> {
149
- await getModel(this.db, 'ttt_sessions').updateWhere(
150
- { id },
151
- { ...patch, updated_at: Date.now() },
152
- );
173
+ return this.createRow(row);
153
174
  }
154
175
 
155
176
  async recordMove(sessionId: string, playerId: string, cell: number, moveIndex: number): Promise<void> {
@@ -174,19 +195,6 @@ export class SessionService {
174
195
  return rows.map((r) => r.user_id);
175
196
  }
176
197
 
177
- async abortStale(idleMs: number): Promise<number> {
178
- const cutoff = Date.now() - idleMs;
179
- const sessions = getModel(this.db, 'ttt_sessions');
180
- const rows = await sessions.findAll({ status: 'active' });
181
- let n = 0;
182
- for (const row of rows) {
183
- if (row.updated_at < cutoff) {
184
- await sessions.updateWhere({ id: row.id }, { status: 'aborted', updated_at: Date.now() });
185
- n++;
186
- }
187
- }
188
- return n;
189
- }
190
198
  }
191
199
 
192
200
  export type SessionServices = { queue: QueueService; session: SessionService };
@@ -194,4 +202,3 @@ export type SessionServices = { queue: QueueService; session: SessionService };
194
202
  export function createServices(db: TttDatabase): SessionServices {
195
203
  return { queue: new QueueService(db), session: new SessionService(db) };
196
204
  }
197
-
@@ -1,5 +1,8 @@
1
- import type { Message, Plugin } from '@zhin.js/core';
2
- import { channelKey } from '@zhin.js/game-kit';
1
+ import {
2
+ channelKey,
3
+ type GameMessageLike,
4
+ type GameReply,
5
+ } from '@zhin.js/game-kit';
3
6
  import { formatPlayerWithMark, formatRosterLine } from './player-label.js';
4
7
  import { startBotGame, startPvpGame } from './game-flow.js';
5
8
  import type { SessionServices } from './session-service.js';
@@ -15,11 +18,10 @@ export const TTT_HELP = [
15
18
  ].join('\n');
16
19
 
17
20
  export async function runTttCommand(
18
- plugin: Plugin | null,
19
21
  services: SessionServices,
20
- message: Message<any>,
22
+ message: GameMessageLike,
21
23
  action: string,
22
- ): Promise<string | undefined> {
24
+ ): Promise<GameReply> {
23
25
  const ch = channelKey(message);
24
26
  const userId = message.$sender.id;
25
27
 
@@ -43,9 +45,13 @@ export async function runTttCommand(
43
45
  const pair = await services.queue.tryMatch(ch);
44
46
  if (pair) {
45
47
  const [px, po] = pair;
46
- const board = await startPvpGame(plugin, services, message, px, po);
48
+ const board = await startPvpGame(services, message, px, po);
47
49
  const matchLine = `匹配成功!${formatPlayerWithMark(px.id, px.displayName, '✕')} vs ${formatPlayerWithMark(po.id, po.displayName, '○')}`;
48
- return board ? `${matchLine}\n\n${board}` : matchLine;
50
+ return board
51
+ ? Array.isArray(board)
52
+ ? [matchLine, '\n\n', ...board]
53
+ : [matchLine, '\n\n', board]
54
+ : matchLine;
49
55
  }
50
56
  return `已加入排队(第 ${position} 位),凑满 2 人自动开局。`;
51
57
  }
@@ -56,7 +62,7 @@ export async function runTttCommand(
56
62
  }
57
63
 
58
64
  if (action === 'bot') {
59
- return startBotGame(plugin, services, message);
65
+ return startBotGame(services, message);
60
66
  }
61
67
 
62
68
  if (action === 'quit') {
@@ -75,12 +81,3 @@ export async function runTttCommand(
75
81
 
76
82
  return `未知子命令:${action}\n\n${TTT_HELP}`;
77
83
  }
78
-
79
- /** Plugin Runtime / smoke: text-only, no Adapter.editMessage. */
80
- export async function runTttCommandText(
81
- services: SessionServices,
82
- message: Message<any>,
83
- action: string,
84
- ): Promise<string> {
85
- return (await runTttCommand(null, services, message, action)) ?? '';
86
- }
@@ -1,6 +0,0 @@
1
- import type { DatabaseHost } from '@zhin.js/plugin-runtime';
2
- import { type TttDatabase, type SessionServices } from './session-service.js';
3
- /** In-memory ttt tables for Plugin Runtime slice-2 (no DatabaseFeature). */
4
- export declare function createInMemoryTttDb(): TttDatabase;
5
- export declare function mountTttMemoryServices(): SessionServices;
6
- export declare function mountTttHostServices(host: DatabaseHost): SessionServices;
package/lib/memory-db.js DELETED
@@ -1,18 +0,0 @@
1
- import { createHostGameDb, createInMemoryGameDb } from '@zhin.js/game-kit';
2
- import { defineHostTables } from './models.js';
3
- import { createServices } from './session-service.js';
4
- const TTT_TABLES = ['ttt_sessions', 'ttt_queue', 'ttt_moves', 'ttt_spectators'];
5
- /** In-memory ttt tables for Plugin Runtime slice-2 (no DatabaseFeature). */
6
- export function createInMemoryTttDb() {
7
- return createInMemoryGameDb(TTT_TABLES);
8
- }
9
- export function mountTttMemoryServices() {
10
- const services = createServices(createInMemoryTttDb());
11
- return services;
12
- }
13
- export function mountTttHostServices(host) {
14
- defineHostTables(host);
15
- const services = createServices(createHostGameDb(host, TTT_TABLES));
16
- return services;
17
- }
18
- //# sourceMappingURL=memory-db.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"memory-db.js","sourceRoot":"","sources":["../src/memory-db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAE3E,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,cAAc,EAA0C,MAAM,sBAAsB,CAAC;AAE9F,MAAM,UAAU,GAAG,CAAC,cAAc,EAAE,WAAW,EAAE,WAAW,EAAE,gBAAgB,CAAU,CAAC;AAEzF,4EAA4E;AAC5E,MAAM,UAAU,mBAAmB;IACjC,OAAO,oBAAoB,CAAC,UAAU,CAA2B,CAAC;AACpE,CAAC;AAED,MAAM,UAAU,sBAAsB;IACpC,MAAM,QAAQ,GAAG,cAAc,CAAC,mBAAmB,EAAE,CAAC,CAAC;IACvD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,IAAkB;IACrD,gBAAgB,CAAC,IAAI,CAAC,CAAC;IACvB,MAAM,QAAQ,GAAG,cAAc,CAAC,gBAAgB,CAAC,IAAI,EAAE,UAAU,CAA2B,CAAC,CAAC;IAC9F,OAAO,QAAQ,CAAC;AAClB,CAAC"}
package/plugin.ts DELETED
@@ -1,64 +0,0 @@
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/src/memory-db.ts DELETED
@@ -1,22 +0,0 @@
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
- }