@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.
- package/commands/ttt/[action:string=].js +16 -0
- package/commands/ttt/[action:string=].ts +2 -2
- package/lib/game-flow.d.ts +7 -6
- package/lib/game-flow.js +33 -73
- package/lib/game-flow.js.map +1 -1
- package/lib/index.d.ts +0 -1
- package/lib/index.js +0 -1
- package/lib/index.js.map +1 -1
- package/lib/models.d.ts +0 -1
- package/lib/models.js +0 -1
- package/lib/models.js.map +1 -1
- package/lib/session-service.d.ts +8 -10
- package/lib/session-service.js +55 -51
- package/lib/session-service.js.map +1 -1
- package/lib/ttt-command.d.ts +2 -4
- package/lib/ttt-command.js +9 -9
- package/lib/ttt-command.js.map +1 -1
- package/middlewares/ttt-alias.js +18 -0
- package/middlewares/ttt-alias.ts +6 -3
- package/middlewares/ttt-choice.js +66 -0
- package/middlewares/ttt-choice.ts +3 -3
- package/package.json +8 -8
- package/plugin.js +84 -0
- package/src/board-view.ts +1 -1
- package/src/game-flow.ts +45 -90
- package/src/index.ts +0 -1
- package/src/models.ts +0 -2
- package/src/session-service.ts +64 -57
- package/src/ttt-command.ts +14 -17
- package/lib/memory-db.d.ts +0 -6
- package/lib/memory-db.js +0 -18
- package/lib/memory-db.js.map +0 -1
- package/plugin.ts +0 -64
- package/src/memory-db.ts +0 -22
package/lib/ttt-command.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
package/lib/ttt-command.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ttt-command.js","sourceRoot":"","sources":["../src/ttt-command.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ttt-command.js","sourceRoot":"","sources":["../src/ttt-command.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,GAGX,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,oBAAoB,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAG5D,MAAM,CAAC,MAAM,QAAQ,GAAG;IACtB,KAAK;IACL,sBAAsB;IACtB,sBAAsB;IACtB,0BAA0B;IAC1B,eAAe;IACf,gBAAgB;IAChB,iBAAiB;CAClB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAEb,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,QAAyB,EACzB,OAAwB,EACxB,MAAc;IAEd,MAAM,EAAE,GAAG,UAAU,CAAC,OAAO,CAAC,CAAC;IAC/B,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;IAElC,IAAI,CAAC,MAAM,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;QACjC,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,OAAO,CAAC,kBAAkB,CAAC,EAAE,CAAC,CAAC;QAC7D,MAAM,CAAC,GAAG,MAAM,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QACzC,MAAM,KAAK,GAAG,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;QAC7B,IAAI,MAAM;YAAE,KAAK,CAAC,IAAI,CAAC,OAAO,gBAAgB,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QAC1D,IAAI,CAAC,GAAG,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QACnC,IAAI,CAAC,MAAM,IAAI,CAAC,KAAK,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC;QAC/D,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;IAED,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;QACtB,IAAI,OAAO,CAAC,QAAQ,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YACxC,OAAO,gBAAgB,CAAC;QAC1B,CAAC;QACD,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,OAAO,CAAC,gBAAgB,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;QACnE,IAAI,MAAM;YAAE,OAAO,aAAa,CAAC;QACjC,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACjF,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;QAC/C,IAAI,IAAI,EAAE,CAAC;YACT,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC;YACtB,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC;YAC5D,MAAM,SAAS,GAAG,QAAQ,oBAAoB,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,WAAW,EAAE,GAAG,CAAC,OAAO,oBAAoB,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,WAAW,EAAE,GAAG,CAAC,EAAE,CAAC;YACpI,OAAO,KAAK;gBACV,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;oBACpB,CAAC,CAAC,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC;oBAC/B,CAAC,CAAC,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC;gBAC9B,CAAC,CAAC,SAAS,CAAC;QAChB,CAAC;QACD,OAAO,WAAW,QAAQ,iBAAiB,CAAC;IAC9C,CAAC;IAED,IAAI,MAAM,KAAK,OAAO,EAAE,CAAC;QACvB,MAAM,EAAE,GAAG,MAAM,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;QAClD,OAAO,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC;IACnC,CAAC;IAED,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;QACrB,OAAO,YAAY,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;IACzC,CAAC;IAED,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;QACtB,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,OAAO,CAAC,gBAAgB,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;QAChE,IAAI,CAAC,GAAG;YAAE,OAAO,YAAY,CAAC;QAC9B,MAAM,QAAQ,CAAC,OAAO,CAAC,aAAa,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC;QAC/E,OAAO,YAAY,CAAC;IACtB,CAAC;IAED,IAAI,MAAM,KAAK,UAAU,EAAE,CAAC;QAC1B,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,OAAO,CAAC,kBAAkB,CAAC,EAAE,CAAC,CAAC;QAC7D,IAAI,CAAC,MAAM;YAAE,OAAO,eAAe,CAAC;QACpC,MAAM,QAAQ,CAAC,OAAO,CAAC,YAAY,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;QACvD,OAAO,WAAW,MAAM,CAAC,EAAE,eAAe,CAAC;IAC7C,CAAC;IAED,OAAO,SAAS,MAAM,OAAO,QAAQ,EAAE,CAAC;AAC1C,CAAC"}
|
|
@@ -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
|
+
});
|
package/middlewares/ttt-alias.ts
CHANGED
|
@@ -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,
|
|
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 (
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
"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.
|
|
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.
|
|
28
|
-
"@zhin.js/core": "1.4.
|
|
29
|
-
"@zhin.js/game-kit": "2.0.
|
|
30
|
-
"@zhin.js/middleware": "1.0.
|
|
31
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
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.
|
|
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
package/src/game-flow.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
):
|
|
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
|
-
|
|
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:
|
|
59
|
+
message: GameMessageLike,
|
|
86
60
|
sessionId: string,
|
|
87
|
-
): Promise<
|
|
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
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
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:
|
|
97
|
+
message: GameMessageLike,
|
|
114
98
|
sessionId: string,
|
|
115
99
|
cell: number,
|
|
116
|
-
): Promise<
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
153
|
+
return runBotMove(services, message, updated);
|
|
196
154
|
}
|
|
197
155
|
|
|
198
156
|
const status = formatTurnStatus(updated, moveCount);
|
|
199
|
-
|
|
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:
|
|
162
|
+
message: GameMessageLike,
|
|
207
163
|
session: TttSessionRow,
|
|
208
|
-
): Promise<
|
|
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
|
|
173
|
+
} as GameMessageLike;
|
|
218
174
|
|
|
219
|
-
return handleMove(
|
|
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:
|
|
226
|
-
): Promise<
|
|
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
|
|
242
|
-
|
|
243
|
-
|
|
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:
|
|
203
|
+
message: GameMessageLike,
|
|
252
204
|
playerX: TttPlayerRef,
|
|
253
205
|
playerO: TttPlayerRef,
|
|
254
|
-
): Promise<
|
|
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
|
-
|
|
266
|
-
if (!plugin) return boardText;
|
|
221
|
+
return renderBoard(message, session, status, false);
|
|
267
222
|
}
|
package/src/index.ts
CHANGED
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 },
|