@zhin.js/game-shared 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +163 -0
  2. package/lib/board-sender.d.ts +14 -0
  3. package/lib/board-sender.d.ts.map +1 -0
  4. package/lib/board-sender.js +7 -0
  5. package/lib/board-sender.js.map +1 -0
  6. package/lib/choice-keyboard.d.ts +37 -0
  7. package/lib/choice-keyboard.d.ts.map +1 -0
  8. package/lib/choice-keyboard.js +60 -0
  9. package/lib/choice-keyboard.js.map +1 -0
  10. package/lib/game-action-alias.d.ts +8 -0
  11. package/lib/game-action-alias.d.ts.map +1 -0
  12. package/lib/game-action-alias.js +117 -0
  13. package/lib/game-action-alias.js.map +1 -0
  14. package/lib/game-hub-feature.d.ts +58 -0
  15. package/lib/game-hub-feature.d.ts.map +1 -0
  16. package/lib/game-hub-feature.js +89 -0
  17. package/lib/game-hub-feature.js.map +1 -0
  18. package/lib/game-hub-flow.d.ts +9 -0
  19. package/lib/game-hub-flow.d.ts.map +1 -0
  20. package/lib/game-hub-flow.js +90 -0
  21. package/lib/game-hub-flow.js.map +1 -0
  22. package/lib/game-hub-menu-context.d.ts +25 -0
  23. package/lib/game-hub-menu-context.d.ts.map +1 -0
  24. package/lib/game-hub-menu-context.js +61 -0
  25. package/lib/game-hub-menu-context.js.map +1 -0
  26. package/lib/game-hub-menu.d.ts +24 -0
  27. package/lib/game-hub-menu.d.ts.map +1 -0
  28. package/lib/game-hub-menu.js +114 -0
  29. package/lib/game-hub-menu.js.map +1 -0
  30. package/lib/game-hub-mount.d.ts +9 -0
  31. package/lib/game-hub-mount.d.ts.map +1 -0
  32. package/lib/game-hub-mount.js +72 -0
  33. package/lib/game-hub-mount.js.map +1 -0
  34. package/lib/game-middleware.d.ts +6 -0
  35. package/lib/game-middleware.d.ts.map +1 -0
  36. package/lib/game-middleware.js +9 -0
  37. package/lib/game-middleware.js.map +1 -0
  38. package/lib/game-registry.d.ts +3 -0
  39. package/lib/game-registry.d.ts.map +1 -0
  40. package/lib/game-registry.js +2 -0
  41. package/lib/game-registry.js.map +1 -0
  42. package/lib/game-session.d.ts +62 -0
  43. package/lib/game-session.d.ts.map +1 -0
  44. package/lib/game-session.js +34 -0
  45. package/lib/game-session.js.map +1 -0
  46. package/lib/grid-keyboard.d.ts +60 -0
  47. package/lib/grid-keyboard.d.ts.map +1 -0
  48. package/lib/grid-keyboard.js +82 -0
  49. package/lib/grid-keyboard.js.map +1 -0
  50. package/lib/index.d.ts +17 -0
  51. package/lib/index.d.ts.map +1 -0
  52. package/lib/index.js +17 -0
  53. package/lib/index.js.map +1 -0
  54. package/package.json +32 -0
  55. package/src/board-sender.ts +16 -0
  56. package/src/choice-keyboard.ts +103 -0
  57. package/src/game-action-alias.ts +129 -0
  58. package/src/game-hub-feature.ts +147 -0
  59. package/src/game-hub-flow.ts +114 -0
  60. package/src/game-hub-menu-context.ts +91 -0
  61. package/src/game-hub-menu.ts +134 -0
  62. package/src/game-hub-mount.ts +108 -0
  63. package/src/game-middleware.ts +14 -0
  64. package/src/game-session.ts +88 -0
  65. package/src/grid-keyboard.ts +142 -0
  66. package/src/index.ts +17 -0
@@ -0,0 +1,114 @@
1
+ import type { Message, Plugin } from 'zhin.js';
2
+ import { parseChoicePayload } from './choice-keyboard.js';
3
+ import {
4
+ buildGameHubMenu,
5
+ buildMainHubMenu,
6
+ formatHubEmptyMessage,
7
+ HUB_PREFIX,
8
+ hubActionChoiceId,
9
+ hubGameChoiceId,
10
+ parseHubChoiceId,
11
+ } from './game-hub-menu.js';
12
+ import { getRegisteredGame, getRegisteredGames } from './game-hub-feature.js';
13
+ import {
14
+ createHubScope,
15
+ getHubContext,
16
+ rememberHubMenu,
17
+ type HubMenuChoice,
18
+ } from './game-hub-menu-context.js';
19
+
20
+ function mainMenuChoices(): HubMenuChoice[] {
21
+ return getRegisteredGames().map((g) => ({
22
+ id: hubGameChoiceId(g.id),
23
+ label: `${g.icon} ${g.title}`,
24
+ }));
25
+ }
26
+
27
+ function gameMenuChoices(gameId: string, channelType: string): HubMenuChoice[] {
28
+ const game = getRegisteredGame(gameId);
29
+ if (!game) return [];
30
+ const isGroup = channelType !== 'private';
31
+ const menus = game.menus.filter((m) => {
32
+ if (m.groupOnly && !isGroup) return false;
33
+ if (m.privateOnly && isGroup) return false;
34
+ return true;
35
+ });
36
+ return [
37
+ ...menus.map((m) => ({
38
+ id: hubActionChoiceId(game.id, m.id),
39
+ label: m.label,
40
+ })),
41
+ { id: 'back', label: '↩️ 返回大厅' },
42
+ ];
43
+ }
44
+
45
+ export function openMainMenu(message: Message<any>): ReturnType<typeof buildMainHubMenu> | string {
46
+ const games = getRegisteredGames();
47
+ if (!games.length) {
48
+ return formatHubEmptyMessage();
49
+ }
50
+ const scopeId = createHubScope(message);
51
+ const choices = mainMenuChoices();
52
+ rememberHubMenu(message, scopeId, choices);
53
+ return buildMainHubMenu(scopeId, games);
54
+ }
55
+
56
+ export async function handleHubChoice(
57
+ plugin: Plugin,
58
+ message: Message<any>,
59
+ scopeId: string,
60
+ choiceId: string,
61
+ ): Promise<boolean> {
62
+ const ctx = getHubContext(scopeId);
63
+ if (!ctx) {
64
+ await message.$reply?.('菜单已过期,请重新发送「游戏」。');
65
+ return true;
66
+ }
67
+ const ch = `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
68
+ if (ctx.channelKey !== ch) {
69
+ await message.$reply?.('请在本频道使用游戏大厅。');
70
+ return true;
71
+ }
72
+
73
+ const parsed = parseHubChoiceId(choiceId);
74
+ if (!parsed) return false;
75
+
76
+ // 大厅导航(选游戏 / 返回):同频道任何人都可点;具体开局由 runAction 用当前点击者 message
77
+ if (parsed.kind === 'back') {
78
+ const games = getRegisteredGames();
79
+ const choices = mainMenuChoices();
80
+ rememberHubMenu(message, scopeId, choices);
81
+ await message.$reply?.(buildMainHubMenu(scopeId, games));
82
+ return true;
83
+ }
84
+
85
+ if (parsed.kind === 'game') {
86
+ const game = getRegisteredGame(parsed.gameId);
87
+ if (!game) {
88
+ await message.$reply?.('该游戏未安装。');
89
+ return true;
90
+ }
91
+ const choices = gameMenuChoices(parsed.gameId, message.$channel.type);
92
+ rememberHubMenu(message, scopeId, choices);
93
+ await message.$reply?.(
94
+ buildGameHubMenu(scopeId, game, message.$channel.type),
95
+ );
96
+ return true;
97
+ }
98
+
99
+ const game = getRegisteredGame(parsed.gameId);
100
+ if (!game) {
101
+ await message.$reply?.('该游戏未安装。');
102
+ return true;
103
+ }
104
+
105
+ const result = await game.runAction(parsed.actionId, { plugin, message });
106
+ if (typeof result === 'string') await message.$reply?.(result);
107
+ return true;
108
+ }
109
+
110
+ export function parseHubPayload(payload: string): { scopeId: string; choiceId: string } | null {
111
+ const parsed = parseChoicePayload(payload, HUB_PREFIX);
112
+ if (!parsed) return null;
113
+ return { scopeId: parsed.sessionId, choiceId: parsed.choiceId };
114
+ }
@@ -0,0 +1,91 @@
1
+ import type { Message } from 'zhin.js';
2
+ import { channelKey } from './board-sender.js';
3
+
4
+ export interface HubMenuContext {
5
+ channelKey: string;
6
+ /** 打开菜单的用户(仅记录,大厅导航不限制点击者) */
7
+ openerId: string;
8
+ message: Message<any>;
9
+ expiresAt: number;
10
+ }
11
+
12
+ export interface HubMenuChoice {
13
+ id: string;
14
+ label: string;
15
+ disabled?: boolean;
16
+ }
17
+
18
+ export interface LastHubMenu {
19
+ scopeId: string;
20
+ choices: HubMenuChoice[];
21
+ expiresAt: number;
22
+ }
23
+
24
+ const TTL_MS = 60 * 60 * 1000;
25
+ const contexts = new Map<string, HubMenuContext>();
26
+ const lastMenus = new Map<string, LastHubMenu>();
27
+
28
+ function userChannelKey(message: Message<any>): string {
29
+ return `${channelKey(message)}:${message.$sender.id}`;
30
+ }
31
+
32
+ export function createHubScope(message: Message<any>): string {
33
+ const scopeId = `h${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`;
34
+ contexts.set(scopeId, {
35
+ channelKey: channelKey(message),
36
+ openerId: message.$sender.id,
37
+ message,
38
+ expiresAt: Date.now() + TTL_MS,
39
+ });
40
+ pruneExpired();
41
+ return scopeId;
42
+ }
43
+
44
+ export function rememberHubMenu(
45
+ message: Message<any>,
46
+ scopeId: string,
47
+ choices: HubMenuChoice[],
48
+ ): void {
49
+ lastMenus.set(userChannelKey(message), {
50
+ scopeId,
51
+ choices,
52
+ expiresAt: Date.now() + TTL_MS,
53
+ });
54
+ pruneExpired();
55
+ }
56
+
57
+ export function getLastHubMenu(message: Message<any>): LastHubMenu | null {
58
+ pruneExpired();
59
+ const last = lastMenus.get(userChannelKey(message));
60
+ if (!last || last.expiresAt < Date.now()) {
61
+ lastMenus.delete(userChannelKey(message));
62
+ return null;
63
+ }
64
+ return last;
65
+ }
66
+
67
+ export function getHubContext(scopeId: string): HubMenuContext | null {
68
+ pruneExpired();
69
+ const ctx = contexts.get(scopeId);
70
+ if (!ctx || ctx.expiresAt < Date.now()) {
71
+ contexts.delete(scopeId);
72
+ return null;
73
+ }
74
+ return ctx;
75
+ }
76
+
77
+ function pruneExpired(): void {
78
+ const now = Date.now();
79
+ for (const [id, ctx] of contexts) {
80
+ if (ctx.expiresAt < now) contexts.delete(id);
81
+ }
82
+ for (const [key, menu] of lastMenus) {
83
+ if (menu.expiresAt < now) lastMenus.delete(key);
84
+ }
85
+ }
86
+
87
+ /** 测试专用:清空菜单上下文 */
88
+ export function resetHubMenuContextForTests(): void {
89
+ contexts.clear();
90
+ lastMenus.clear();
91
+ }
@@ -0,0 +1,134 @@
1
+ import type { SendContent } from 'zhin.js';
2
+ import { buildChoiceKeyboard } from './choice-keyboard.js';
3
+ import type { RegisteredGame } from './game-hub-feature.js';
4
+
5
+ export const HUB_PREFIX = 'hub';
6
+
7
+ export function hubGameChoiceId(gameId: string): string {
8
+ return `g_${gameId}`;
9
+ }
10
+
11
+ export function hubActionChoiceId(gameId: string, actionId: string): string {
12
+ return `a_${gameId}_${actionId}`;
13
+ }
14
+
15
+ export function parseHubChoiceId(
16
+ choiceId: string,
17
+ ):
18
+ | { kind: 'game'; gameId: string }
19
+ | { kind: 'action'; gameId: string; actionId: string }
20
+ | { kind: 'back' }
21
+ | null {
22
+ if (choiceId === 'back') return { kind: 'back' };
23
+ if (choiceId.startsWith('g_')) return { kind: 'game', gameId: choiceId.slice(2) };
24
+ if (choiceId.startsWith('a_')) {
25
+ const rest = choiceId.slice(2);
26
+ const sep = rest.indexOf('_');
27
+ if (sep <= 0) return null;
28
+ return {
29
+ kind: 'action',
30
+ gameId: rest.slice(0, sep),
31
+ actionId: rest.slice(sep + 1),
32
+ };
33
+ }
34
+ return null;
35
+ }
36
+
37
+ export function buildMainHubMenu(scopeId: string, games: readonly RegisteredGame[]): SendContent {
38
+ const cmdHint = formatCommandPrefixHint(games);
39
+ const lines = [
40
+ '🎮 **游戏大厅**',
41
+ '',
42
+ cmdHint,
43
+ '',
44
+ '_同频道均可浏览;开局后仅对局玩家可操作棋盘。_',
45
+ '',
46
+ ];
47
+ for (const g of games) {
48
+ lines.push(`• ${g.icon} **${g.title}** — ${g.description}`);
49
+ }
50
+
51
+ return buildChoiceKeyboard({
52
+ gamePrefix: HUB_PREFIX,
53
+ sessionId: scopeId,
54
+ narrative: lines.join('\n'),
55
+ choices: games.map((g) => ({
56
+ id: hubGameChoiceId(g.id),
57
+ label: `${g.icon} ${g.title}`,
58
+ style: 'primary' as const,
59
+ })),
60
+ buttonsPerRow: 2,
61
+ fallbackHint: '回复数字进入对应游戏',
62
+ });
63
+ }
64
+
65
+ export function buildGameHubMenu(
66
+ scopeId: string,
67
+ game: RegisteredGame,
68
+ channelType: string,
69
+ ): SendContent {
70
+ const isGroup = channelType !== 'private';
71
+ const menus = game.menus.filter((m) => {
72
+ if (m.groupOnly && !isGroup) return false;
73
+ if (m.privateOnly && isGroup) return false;
74
+ return true;
75
+ });
76
+
77
+ return buildChoiceKeyboard({
78
+ gamePrefix: HUB_PREFIX,
79
+ sessionId: scopeId,
80
+ narrative: `${game.icon} **${game.title}**\n\n${game.description}\n\n请选择:`,
81
+ choices: [
82
+ ...menus.map((m) => ({
83
+ id: hubActionChoiceId(game.id, m.id),
84
+ label: m.label,
85
+ style: m.style,
86
+ })),
87
+ { id: 'back', label: '↩️ 返回大厅' },
88
+ ],
89
+ buttonsPerRow: 2,
90
+ fallbackHint: '回复数字选择操作',
91
+ });
92
+ }
93
+
94
+ /** 大厅主菜单:命令前缀提示 */
95
+ export function formatCommandPrefixHint(games: readonly RegisteredGame[]): string {
96
+ if (!games.length) return '暂无可用游戏。';
97
+ const sample = games.slice(0, 5).map((g) => g.commandPrefix);
98
+ const tail = games.length > sample.length ? '等' : '';
99
+ return `选择想玩的游戏(也可直接发送中文命令,如「${sample.join('」「')}」${tail}):`;
100
+ }
101
+
102
+ /** 游戏大厅帮助文案(随 registerGame 动态生成) */
103
+ export function formatHubHelp(games: readonly RegisteredGame[]): string {
104
+ const lines = [
105
+ '🎮 **游戏大厅**',
106
+ '',
107
+ '游戏 — 打开可点击的游戏菜单',
108
+ 'game — 同上(英文)',
109
+ ];
110
+
111
+ if (!games.length) {
112
+ lines.push('', '当前暂无已注册游戏。', '请安装任意游戏插件(registerGame 会自动挂载大厅)。');
113
+ return lines.join('\n');
114
+ }
115
+
116
+ lines.push('', `当前已注册 **${games.length}** 款游戏:`, '');
117
+
118
+ for (const g of games) {
119
+ const quick = g.quickStart ?? '开始';
120
+ const alias = g.aliases?.length ? ` · ${g.aliases.join(' / ')}` : '';
121
+ lines.push(`• ${g.icon} **${g.title}**${alias}`);
122
+ lines.push(` 命令:\`${g.commandPrefix} ${quick}\` — ${g.description}`);
123
+ }
124
+
125
+ return lines.join('\n');
126
+ }
127
+
128
+ /** 无游戏时的提示 */
129
+ export function formatHubEmptyMessage(): string {
130
+ return [
131
+ '游戏大厅暂无可用游戏。',
132
+ '请安装任意游戏插件;首个游戏 registerGame 时会自动挂载大厅命令。',
133
+ ].join('');
134
+ }
@@ -0,0 +1,108 @@
1
+ import {
2
+ Message,
3
+ MessageCommand,
4
+ getActionFromMessage,
5
+ type Plugin,
6
+ } from 'zhin.js';
7
+ import { buildChoiceFallbackMap, parseChoicePayload } from './choice-keyboard.js';
8
+ import {
9
+ formatHubEmptyMessage,
10
+ formatHubHelp,
11
+ HUB_PREFIX,
12
+ } from './game-hub-menu.js';
13
+ import { getLastHubMenu } from './game-hub-menu-context.js';
14
+ import { handleHubChoice, openMainMenu, parseHubPayload } from './game-hub-flow.js';
15
+ import { getRegisteredGames } from './game-hub-feature.js';
16
+
17
+ /**
18
+ * 在 root 上挂载「游戏 / game」大厅命令,返回 dispose 列表。
19
+ */
20
+ export function mountGameHubUi(root: Plugin): (() => void)[] {
21
+ const disposers: (() => void)[] = [];
22
+
23
+ const openHandler = async (message: Message<any>) => {
24
+ const games = getRegisteredGames();
25
+ if (!games.length) {
26
+ return formatHubEmptyMessage();
27
+ }
28
+ const menu = openMainMenu(message);
29
+ if (typeof menu === 'string') return menu;
30
+ await message.$reply?.(menu);
31
+ return undefined;
32
+ };
33
+
34
+ disposers.push(
35
+ root.addCommand(
36
+ new MessageCommand('游戏')
37
+ .desc('游戏大厅:选择游戏并开始')
38
+ .action(openHandler),
39
+ ),
40
+ );
41
+
42
+ disposers.push(
43
+ root.addCommand(
44
+ new MessageCommand('game')
45
+ .desc('Game lobby (English alias)')
46
+ .action(openHandler),
47
+ ),
48
+ );
49
+
50
+ disposers.push(
51
+ root.addCommand(
52
+ new MessageCommand('游戏 帮助')
53
+ .desc('游戏大厅帮助')
54
+ .action(async () => formatHubHelp(getRegisteredGames())),
55
+ ),
56
+ );
57
+
58
+ root.registerInteractiveHandler(`${HUB_PREFIX}:`, (message) =>
59
+ handleHubInteractive(root, message),
60
+ );
61
+
62
+ disposers.push(
63
+ root.addMiddleware(async (message, next) => {
64
+ const action = getActionFromMessage(message);
65
+ if (action?.payload.startsWith(`${HUB_PREFIX}:`)) return next();
66
+
67
+ const raw = message.$raw?.trim() ?? '';
68
+ const n = /^(\d+)$/.exec(raw);
69
+ if (!n) return next();
70
+
71
+ const last = getLastHubMenu(message);
72
+ if (!last) return next();
73
+
74
+ const map = buildChoiceFallbackMap(HUB_PREFIX, last.scopeId, last.choices);
75
+ const payload = map[n[1]!];
76
+ const parsed = payload ? parseChoicePayload(payload, HUB_PREFIX) : null;
77
+ if (!parsed || parsed.sessionId !== last.scopeId) return next();
78
+
79
+ await handleHubChoice(root, message, parsed.sessionId, parsed.choiceId);
80
+ }),
81
+ );
82
+
83
+ return disposers;
84
+ }
85
+
86
+ async function handleHubInteractive(root: Plugin, message: Message<any>): Promise<boolean> {
87
+ const action = getActionFromMessage(message);
88
+ if (!action) return false;
89
+
90
+ const fromPayload = parseHubPayload(action.payload);
91
+ if (!fromPayload) return false;
92
+
93
+ return handleHubChoice(root, message, fromPayload.scopeId, fromPayload.choiceId);
94
+ }
95
+
96
+ let hubUiMounted = false;
97
+
98
+ export function isGameHubMounted(): boolean {
99
+ return hubUiMounted;
100
+ }
101
+
102
+ export function markGameHubUiMounted(): void {
103
+ hubUiMounted = true;
104
+ }
105
+
106
+ export function resetGameHubMountForTests(): void {
107
+ hubUiMounted = false;
108
+ }
@@ -0,0 +1,14 @@
1
+ import type { MessageMiddleware, Plugin, RegisteredAdapter } from 'zhin.js';
2
+
3
+ /**
4
+ * 在 root 插件上注册游戏文本中间件(入站管线只走 root.middleware)。
5
+ */
6
+ export function registerGameTextMiddleware(
7
+ plugin: Plugin,
8
+ middleware: MessageMiddleware<RegisteredAdapter>,
9
+ name?: string,
10
+ ): () => void {
11
+ const dispose = plugin.root.addMiddleware(middleware, name);
12
+ plugin.onDispose(dispose);
13
+ return dispose;
14
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 通用游戏会话接口
3
+ * 定义回合制游戏的基础会话结构
4
+ */
5
+ import type { Message } from 'zhin.js';
6
+
7
+ /** 会话状态 */
8
+ export type GameStatus = 'active' | 'won' | 'draw' | 'aborted';
9
+
10
+ /** 通用游戏会话接口 */
11
+ export interface GameSession {
12
+ /** 会话 ID */
13
+ id: string;
14
+ /** 适配器名称 */
15
+ adapter: string;
16
+ /** Endpoint 名称 */
17
+ endpoint: string;
18
+ /** 频道类型 */
19
+ channel_type: 'private' | 'group' | 'channel';
20
+ /** 频道 ID */
21
+ channel_id: string;
22
+ /** 频道唯一键 */
23
+ channel_key: string;
24
+ /** 棋盘消息 ID(用于编辑) */
25
+ board_message_id: string;
26
+ /** 当前状态 */
27
+ status: GameStatus;
28
+ /** 更新时间戳 */
29
+ updated_at: number;
30
+ /** 创建时间戳 */
31
+ created_at: number;
32
+ }
33
+
34
+ /** 回合制游戏会话(2 人对战) */
35
+ export interface TurnBasedSession extends GameSession {
36
+ /** 玩家 1 ID */
37
+ player_1: string;
38
+ /** 玩家 2 ID */
39
+ player_2: string;
40
+ /** 玩家 1 显示名 */
41
+ player_1_name: string;
42
+ /** 玩家 2 显示名 */
43
+ player_2_name: string;
44
+ /** 当前回合(1 或 2) */
45
+ turn: 1 | 2;
46
+ /** 胜者(0=无,1=玩家1,2=玩家2) */
47
+ winner: 0 | 1 | 2;
48
+ /** 步数 */
49
+ move_count: number;
50
+ }
51
+
52
+ /** 生成会话 ID */
53
+ export function generateSessionId(): string {
54
+ return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
55
+ }
56
+
57
+ /** 获取当前回合玩家 ID */
58
+ export function currentPlayerId(session: TurnBasedSession): string {
59
+ return session.turn === 1 ? session.player_1 : session.player_2;
60
+ }
61
+
62
+ /** 判断用户是否为本局玩家 */
63
+ export function isPlayer(session: TurnBasedSession, userId: string): boolean {
64
+ return session.player_1 === userId || session.player_2 === userId;
65
+ }
66
+
67
+ /** 判断是否轮到该用户 */
68
+ export function isPlayerTurn(session: TurnBasedSession, userId: string): boolean {
69
+ return currentPlayerId(session) === userId;
70
+ }
71
+
72
+ /** 获取玩家编号(1 或 2),非玩家返回 null */
73
+ export function playerNumber(session: TurnBasedSession, userId: string): 1 | 2 | null {
74
+ if (session.player_1 === userId) return 1;
75
+ if (session.player_2 === userId) return 2;
76
+ return null;
77
+ }
78
+
79
+ /** 切换回合 */
80
+ export function nextTurn(current: 1 | 2): 1 | 2 {
81
+ return current === 1 ? 2 : 1;
82
+ }
83
+
84
+ /** 从 Message 提取发送者显示名 */
85
+ export function senderDisplayName(message: Message<any>): string {
86
+ const name = message.$sender.name?.trim();
87
+ return name || message.$sender.id;
88
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * 通用网格按钮键盘构建器
3
+ * 支持井字棋(3×3)、五子棋(15×15)、四子棋(7×6)等
4
+ */
5
+ import { segment, type SendContent } from 'zhin.js';
6
+
7
+ export interface GridCell<T = unknown> {
8
+ /** 单元格状态(游戏自定义,如 0=空, 1=X, 2=O) */
9
+ state: T;
10
+ /** 按钮显示文本 */
11
+ label: string;
12
+ /** 是否禁用(已落子或终局) */
13
+ disabled: boolean;
14
+ /** 高亮(胜利连线等) */
15
+ highlight?: boolean;
16
+ }
17
+
18
+ export interface GridKeyboardOptions<T = unknown> {
19
+ /** 游戏前缀(如 'ttt', 'gomoku', 'c4') */
20
+ gamePrefix: string;
21
+ /** 会话 ID */
22
+ sessionId: string;
23
+ /** 行数 */
24
+ rows: number;
25
+ /** 列数 */
26
+ cols: number;
27
+ /** 单元格数据,按行优先 [row * cols + col] */
28
+ cells: GridCell<T>[];
29
+ /** 状态行文本 */
30
+ statusLine: string;
31
+ /** 是否终局(全部按钮禁用) */
32
+ terminal?: boolean;
33
+ /** 省略 ASCII 文本棋盘(QQ 等平台) */
34
+ omitAsciiBoard?: boolean;
35
+ /** ASCII 棋盘渲染器(可选,省略则不渲染 ASCII) */
36
+ renderAscii?: (cells: GridCell<T>[], rows: number, cols: number, highlight?: number[]) => string;
37
+ /** 高亮的单元格索引(胜利连线等) */
38
+ highlight?: number[];
39
+ /** fallback 提示文本 */
40
+ fallbackHint?: string;
41
+ }
42
+
43
+ /**
44
+ * 构建 fallback 数字映射(仅可落子的格子)
45
+ */
46
+ export function buildGridFallbackMap(
47
+ gamePrefix: string,
48
+ sessionId: string,
49
+ cells: GridCell[],
50
+ ): Record<string, string> {
51
+ const map: Record<string, string> = {};
52
+ let n = 1;
53
+ for (let i = 0; i < cells.length; i++) {
54
+ if (!cells[i]!.disabled) {
55
+ map[String(n)] = `${gamePrefix}:${sessionId}:${i}`;
56
+ n++;
57
+ }
58
+ }
59
+ return map;
60
+ }
61
+
62
+ /**
63
+ * 构建网格按钮键盘消息内容
64
+ */
65
+ export function buildGridKeyboard<T>(options: GridKeyboardOptions<T>): SendContent {
66
+ const {
67
+ gamePrefix,
68
+ sessionId,
69
+ rows,
70
+ cols,
71
+ cells,
72
+ statusLine,
73
+ terminal,
74
+ omitAsciiBoard,
75
+ renderAscii,
76
+ highlight,
77
+ fallbackHint,
78
+ } = options;
79
+
80
+ const buttonRows: ReturnType<typeof segment.button>[][] = [];
81
+
82
+ for (let r = 0; r < rows; r++) {
83
+ const row: ReturnType<typeof segment.button>[] = [];
84
+ for (let c = 0; c < cols; c++) {
85
+ const i = r * cols + c;
86
+ const cell = cells[i]!;
87
+ row.push(
88
+ segment.button({
89
+ id: `c${i}`,
90
+ label: cell.label,
91
+ payload: `${gamePrefix}:${sessionId}:${i}`,
92
+ disabled: terminal || cell.disabled,
93
+ style: cell.highlight ? 'primary' : 'secondary',
94
+ }),
95
+ );
96
+ }
97
+ buttonRows.push(row);
98
+ }
99
+
100
+ const fallback = buildGridFallbackMap(gamePrefix, sessionId, cells);
101
+
102
+ const textLines = [statusLine];
103
+ if (!omitAsciiBoard && renderAscii) {
104
+ textLines.push('', renderAscii(cells, rows, cols, highlight));
105
+ }
106
+
107
+ return [
108
+ segment.text(textLines.join('\n')),
109
+ segment.keyboard(buttonRows, {
110
+ fallback: fallbackHint
111
+ ? { hint: fallbackHint, map: fallback }
112
+ : undefined,
113
+ }).toElement(),
114
+ ];
115
+ }
116
+
117
+ /**
118
+ * 解析网格 payload:`{prefix}:{sessionId}:{cellIndex}`
119
+ */
120
+ export function parseGridPayload(
121
+ payload: string,
122
+ expectedPrefix?: string,
123
+ ): { prefix: string; sessionId: string; cell: number } | null {
124
+ const m = /^([a-z0-9_]+):([^:]+):(\d+)$/i.exec(payload);
125
+ if (!m) return null;
126
+ const prefix = m[1]!;
127
+ if (expectedPrefix && prefix !== expectedPrefix) return null;
128
+ const cell = Number(m[3]);
129
+ if (!Number.isInteger(cell) || cell < 0) return null;
130
+ return { prefix, sessionId: m[2]!, cell };
131
+ }
132
+
133
+ /**
134
+ * 解析按钮 ID 格式:`c{cellIndex}`(QQ 等平台 action 可能只回传 button_id)
135
+ */
136
+ export function parseCellButtonId(value: string): number | null {
137
+ const m = /^c(\d+)$/.exec(value);
138
+ if (!m) return null;
139
+ const cell = Number(m[1]);
140
+ if (!Number.isInteger(cell) || cell < 0) return null;
141
+ return cell;
142
+ }