@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.
- package/commands/ttt/[action:string=].ts +15 -0
- package/lib/board-view.d.ts +3 -3
- package/lib/board-view.js +6 -2
- package/lib/board-view.js.map +1 -1
- package/lib/engine.d.ts +0 -1
- package/lib/game-flow.d.ts +6 -6
- package/lib/game-flow.js +61 -12
- package/lib/game-flow.js.map +1 -1
- package/lib/index.d.ts +8 -2
- package/lib/index.js +6 -34
- package/lib/index.js.map +1 -1
- package/lib/memory-db.d.ts +6 -0
- package/lib/memory-db.js +18 -0
- package/lib/memory-db.js.map +1 -0
- package/lib/models.d.ts +5 -4
- package/lib/models.js +5 -5
- package/lib/models.js.map +1 -1
- package/lib/player-label.d.ts +1 -2
- package/lib/runtime-store.d.ts +6 -0
- package/lib/runtime-store.js +7 -0
- package/lib/runtime-store.js.map +1 -0
- package/lib/session-service.d.ts +1 -3
- package/lib/session-service.js +4 -15
- package/lib/session-service.js.map +1 -1
- package/lib/ttt-command.d.ts +4 -3
- package/lib/ttt-command.js +8 -3
- package/lib/ttt-command.js.map +1 -1
- package/middlewares/ttt-alias.ts +18 -0
- package/middlewares/ttt-choice.ts +71 -0
- package/package.json +36 -18
- package/plugin.ts +64 -0
- package/schema.json +6 -0
- package/src/board-view.ts +8 -3
- package/src/game-flow.ts +71 -21
- package/src/index.ts +7 -41
- package/src/memory-db.ts +22 -0
- package/src/models.ts +10 -10
- package/src/player-label.ts +1 -1
- package/src/runtime-store.ts +9 -0
- package/src/session-service.ts +5 -13
- package/src/ttt-command.ts +15 -5
- package/lib/board-view.d.ts.map +0 -1
- package/lib/commands.d.ts +0 -6
- package/lib/commands.d.ts.map +0 -1
- package/lib/commands.js +0 -111
- package/lib/commands.js.map +0 -1
- package/lib/engine.d.ts.map +0 -1
- package/lib/game-flow.d.ts.map +0 -1
- package/lib/hub-register.d.ts +0 -5
- package/lib/hub-register.d.ts.map +0 -1
- package/lib/hub-register.js +0 -30
- package/lib/hub-register.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/models.d.ts.map +0 -1
- package/lib/player-label.d.ts.map +0 -1
- package/lib/session-service.d.ts.map +0 -1
- package/lib/ttt-command.d.ts.map +0 -1
- package/plugin.yml +0 -2
- package/src/commands.ts +0 -129
- 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.
|
|
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/
|
|
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
|
-
"
|
|
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
|
|
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
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-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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({ 模块: '井字棋', 状态: '已加载' }));
|
package/src/memory-db.ts
ADDED
|
@@ -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
|
|
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
|
-
|
|
60
|
-
|
|
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 },
|
package/src/player-label.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/session-service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Database,
|
|
2
|
-
import { channelKey } from '@zhin.js/game-
|
|
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
|
|
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({
|
|
106
|
+
const rows = await getModel(this.db, 'ttt_sessions').findAll({});
|
|
107
107
|
for (const row of rows) {
|
|
108
|
-
|
|
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
|
-
}
|