@zhin.js/game-kit 1.0.1
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/LICENSE +21 -0
- package/README.md +93 -0
- package/lib/board-sender.d.ts +14 -0
- package/lib/board-sender.d.ts.map +1 -0
- package/lib/board-sender.js +7 -0
- package/lib/board-sender.js.map +1 -0
- package/lib/choice-keyboard.d.ts +44 -0
- package/lib/choice-keyboard.d.ts.map +1 -0
- package/lib/choice-keyboard.js +67 -0
- package/lib/choice-keyboard.js.map +1 -0
- package/lib/command-alias.d.ts +21 -0
- package/lib/command-alias.d.ts.map +1 -0
- package/lib/command-alias.js +22 -0
- package/lib/command-alias.js.map +1 -0
- package/lib/command-help.d.ts +19 -0
- package/lib/command-help.d.ts.map +1 -0
- package/lib/command-help.js +91 -0
- package/lib/command-help.js.map +1 -0
- package/lib/command-message.d.ts +25 -0
- package/lib/command-message.d.ts.map +1 -0
- package/lib/command-message.js +58 -0
- package/lib/command-message.js.map +1 -0
- package/lib/game-action-alias.d.ts +8 -0
- package/lib/game-action-alias.d.ts.map +1 -0
- package/lib/game-action-alias.js +117 -0
- package/lib/game-action-alias.js.map +1 -0
- package/lib/game-commands.d.ts +3 -0
- package/lib/game-commands.d.ts.map +1 -0
- package/lib/game-commands.js +5 -0
- package/lib/game-commands.js.map +1 -0
- package/lib/game-hub-feature.d.ts +58 -0
- package/lib/game-hub-feature.d.ts.map +1 -0
- package/lib/game-hub-feature.js +100 -0
- package/lib/game-hub-feature.js.map +1 -0
- package/lib/game-hub-flow.d.ts +9 -0
- package/lib/game-hub-flow.d.ts.map +1 -0
- package/lib/game-hub-flow.js +90 -0
- package/lib/game-hub-flow.js.map +1 -0
- package/lib/game-hub-menu-context.d.ts +25 -0
- package/lib/game-hub-menu-context.d.ts.map +1 -0
- package/lib/game-hub-menu-context.js +64 -0
- package/lib/game-hub-menu-context.js.map +1 -0
- package/lib/game-hub-menu.d.ts +37 -0
- package/lib/game-hub-menu.d.ts.map +1 -0
- package/lib/game-hub-menu.js +162 -0
- package/lib/game-hub-menu.js.map +1 -0
- package/lib/game-hub-mount.d.ts +9 -0
- package/lib/game-hub-mount.d.ts.map +1 -0
- package/lib/game-hub-mount.js +108 -0
- package/lib/game-hub-mount.js.map +1 -0
- package/lib/game-interactive.d.ts +25 -0
- package/lib/game-interactive.d.ts.map +1 -0
- package/lib/game-interactive.js +62 -0
- package/lib/game-interactive.js.map +1 -0
- package/lib/game-middleware.d.ts +6 -0
- package/lib/game-middleware.d.ts.map +1 -0
- package/lib/game-middleware.js +9 -0
- package/lib/game-middleware.js.map +1 -0
- package/lib/game-onboarding.d.ts +8 -0
- package/lib/game-onboarding.d.ts.map +1 -0
- package/lib/game-onboarding.js +66 -0
- package/lib/game-onboarding.js.map +1 -0
- package/lib/game-records.d.ts +52 -0
- package/lib/game-records.d.ts.map +1 -0
- package/lib/game-records.js +206 -0
- package/lib/game-records.js.map +1 -0
- package/lib/game-session.d.ts +64 -0
- package/lib/game-session.d.ts.map +1 -0
- package/lib/game-session.js +44 -0
- package/lib/game-session.js.map +1 -0
- package/lib/grid-keyboard.d.ts +67 -0
- package/lib/grid-keyboard.d.ts.map +1 -0
- package/lib/grid-keyboard.js +104 -0
- package/lib/grid-keyboard.js.map +1 -0
- package/lib/help-card.d.ts +12 -0
- package/lib/help-card.d.ts.map +1 -0
- package/lib/help-card.js +129 -0
- package/lib/help-card.js.map +1 -0
- package/lib/index.d.ts +29 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +29 -0
- package/lib/index.js.map +1 -0
- package/lib/interaction-profiles.d.ts +16 -0
- package/lib/interaction-profiles.d.ts.map +1 -0
- package/lib/interaction-profiles.js +35 -0
- package/lib/interaction-profiles.js.map +1 -0
- package/lib/memory-db.d.ts +41 -0
- package/lib/memory-db.d.ts.map +1 -0
- package/lib/memory-db.js +92 -0
- package/lib/memory-db.js.map +1 -0
- package/lib/random.d.ts +6 -0
- package/lib/random.d.ts.map +1 -0
- package/lib/random.js +29 -0
- package/lib/random.js.map +1 -0
- package/lib/runtime-hub.d.ts +28 -0
- package/lib/runtime-hub.d.ts.map +1 -0
- package/lib/runtime-hub.js +66 -0
- package/lib/runtime-hub.js.map +1 -0
- package/lib/send-content-text.d.ts +5 -0
- package/lib/send-content-text.d.ts.map +1 -0
- package/lib/send-content-text.js +24 -0
- package/lib/send-content-text.js.map +1 -0
- package/package.json +49 -0
- package/src/board-sender.ts +16 -0
- package/src/choice-keyboard.ts +117 -0
- package/src/command-alias.ts +45 -0
- package/src/command-help.ts +117 -0
- package/src/command-message.ts +76 -0
- package/src/game-action-alias.ts +129 -0
- package/src/game-commands.ts +4 -0
- package/src/game-hub-feature.ts +151 -0
- package/src/game-hub-flow.ts +114 -0
- package/src/game-hub-menu-context.ts +94 -0
- package/src/game-hub-menu.ts +223 -0
- package/src/game-hub-mount.ts +172 -0
- package/src/game-interactive.ts +88 -0
- package/src/game-middleware.ts +14 -0
- package/src/game-onboarding.ts +67 -0
- package/src/game-records.ts +287 -0
- package/src/game-session.ts +97 -0
- package/src/grid-keyboard.ts +180 -0
- package/src/help-card.ts +188 -0
- package/src/index.ts +29 -0
- package/src/interaction-profiles.ts +56 -0
- package/src/memory-db.ts +128 -0
- package/src/random.ts +32 -0
- package/src/runtime-hub.ts +86 -0
- package/src/send-content-text.ts +21 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Message,
|
|
3
|
+
MessageCommand,
|
|
4
|
+
getActionFromMessage,
|
|
5
|
+
resolvePayloadFromText,
|
|
6
|
+
type Plugin,
|
|
7
|
+
} from '@zhin.js/core';
|
|
8
|
+
import { buildChoiceFallbackMap, parseChoicePayload } from './choice-keyboard.js';
|
|
9
|
+
import {
|
|
10
|
+
formatHubEmptyMessage,
|
|
11
|
+
buildBotHelpReply,
|
|
12
|
+
buildHubHelpReply,
|
|
13
|
+
HUB_PREFIX,
|
|
14
|
+
} from './game-hub-menu.js';
|
|
15
|
+
import { getLastHubMenu } from './game-hub-menu-context.js';
|
|
16
|
+
import { handleHubChoice, openMainMenu, parseHubPayload } from './game-hub-flow.js';
|
|
17
|
+
import { getRegisteredGames } from './game-hub-feature.js';
|
|
18
|
+
import { mountGameRecordCommands } from './game-records.js';
|
|
19
|
+
import { mountFirstAtHintMiddleware } from './game-onboarding.js';
|
|
20
|
+
import { filterHelpCommands, type CommandHelpSource } from './command-help.js';
|
|
21
|
+
|
|
22
|
+
function resolveRegisteredCommands(root: Plugin): MessageCommand[] {
|
|
23
|
+
const commandService = root.inject('command') as { items?: MessageCommand[] } | undefined;
|
|
24
|
+
return commandService?.items ?? [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function resolveHelpCommands(root: Plugin, message: Message<any>): Promise<CommandHelpSource[]> {
|
|
28
|
+
return filterHelpCommands(resolveRegisteredCommands(root), message, root);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 在 root 上挂载「游戏 / game」大厅命令,返回 dispose 列表。
|
|
33
|
+
*/
|
|
34
|
+
export function mountGameHubUi(root: Plugin): (() => void)[] {
|
|
35
|
+
const disposers: (() => void)[] = [];
|
|
36
|
+
|
|
37
|
+
const openHandler = async (message: Message<any>) => {
|
|
38
|
+
const games = getRegisteredGames();
|
|
39
|
+
if (!games.length) {
|
|
40
|
+
return formatHubEmptyMessage();
|
|
41
|
+
}
|
|
42
|
+
const menu = openMainMenu(message);
|
|
43
|
+
if (typeof menu === 'string') return menu;
|
|
44
|
+
await message.$reply?.(menu);
|
|
45
|
+
return undefined;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
disposers.push(
|
|
49
|
+
root.addCommand(
|
|
50
|
+
new MessageCommand('/游戏')
|
|
51
|
+
.desc('游戏大厅:选择游戏并开始')
|
|
52
|
+
.action(openHandler),
|
|
53
|
+
),
|
|
54
|
+
root.addCommand(
|
|
55
|
+
new MessageCommand('/game')
|
|
56
|
+
.desc('Game lobby (English alias)')
|
|
57
|
+
.action(openHandler),
|
|
58
|
+
),
|
|
59
|
+
root.addCommand(
|
|
60
|
+
new MessageCommand('游戏')
|
|
61
|
+
.desc('游戏大厅(无斜杠兼容)')
|
|
62
|
+
.action(openHandler),
|
|
63
|
+
),
|
|
64
|
+
root.addCommand(
|
|
65
|
+
new MessageCommand('game')
|
|
66
|
+
.desc('Game lobby (legacy alias)')
|
|
67
|
+
.action(openHandler),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
disposers.push(
|
|
72
|
+
root.addCommand(
|
|
73
|
+
new MessageCommand('/游戏 帮助')
|
|
74
|
+
.desc('游戏大厅帮助')
|
|
75
|
+
.action(async (message) =>
|
|
76
|
+
buildHubHelpReply(getRegisteredGames(), await resolveHelpCommands(root, message)),
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
root.addCommand(
|
|
80
|
+
new MessageCommand('/帮助')
|
|
81
|
+
.desc('机器人帮助:游戏列表与 QQ 群用法')
|
|
82
|
+
.action(async (message) =>
|
|
83
|
+
buildBotHelpReply(getRegisteredGames(), {
|
|
84
|
+
channelType: message.$channel.type,
|
|
85
|
+
commands: await resolveHelpCommands(root, message),
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
root.addCommand(
|
|
90
|
+
new MessageCommand('/help')
|
|
91
|
+
.desc('Bot help (English alias)')
|
|
92
|
+
.action(async (message) =>
|
|
93
|
+
buildBotHelpReply(getRegisteredGames(), {
|
|
94
|
+
channelType: message.$channel.type,
|
|
95
|
+
commands: await resolveHelpCommands(root, message),
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
root.addCommand(
|
|
100
|
+
new MessageCommand('帮助')
|
|
101
|
+
.desc('帮助(无斜杠兼容)')
|
|
102
|
+
.action(async (message) =>
|
|
103
|
+
buildBotHelpReply(getRegisteredGames(), {
|
|
104
|
+
channelType: message.$channel.type,
|
|
105
|
+
commands: await resolveHelpCommands(root, message),
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
disposers.push(mountGameRecordCommands(root));
|
|
112
|
+
disposers.push(mountFirstAtHintMiddleware(root));
|
|
113
|
+
|
|
114
|
+
root.registerInteractiveHandler(`${HUB_PREFIX}:`, (message) =>
|
|
115
|
+
handleHubInteractive(root, message),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
disposers.push(
|
|
119
|
+
root.addMiddleware(async (message, next) => {
|
|
120
|
+
const action = getActionFromMessage(message);
|
|
121
|
+
if (action?.payload.startsWith(`${HUB_PREFIX}:`)) return next();
|
|
122
|
+
|
|
123
|
+
const raw = message.$raw?.trim() ?? '';
|
|
124
|
+
|
|
125
|
+
// QQ 指令预填等:payload 自包含 hub:scope:choice,不依赖 opener 的菜单缓存
|
|
126
|
+
const directPayload = resolvePayloadFromText(raw);
|
|
127
|
+
if (directPayload?.startsWith(`${HUB_PREFIX}:`)) {
|
|
128
|
+
const direct = parseChoicePayload(directPayload, HUB_PREFIX);
|
|
129
|
+
if (direct) {
|
|
130
|
+
await handleHubChoice(root, message, direct.sessionId, direct.choiceId);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const last = getLastHubMenu(message);
|
|
136
|
+
if (!last) return next();
|
|
137
|
+
|
|
138
|
+
const map = buildChoiceFallbackMap(HUB_PREFIX, last.scopeId, last.choices);
|
|
139
|
+
const payload = resolvePayloadFromText(raw, map);
|
|
140
|
+
const parsed = payload ? parseChoicePayload(payload, HUB_PREFIX) : null;
|
|
141
|
+
if (!parsed || parsed.sessionId !== last.scopeId) return next();
|
|
142
|
+
|
|
143
|
+
await handleHubChoice(root, message, parsed.sessionId, parsed.choiceId);
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return disposers;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function handleHubInteractive(root: Plugin, message: Message<any>): Promise<boolean> {
|
|
151
|
+
const action = getActionFromMessage(message);
|
|
152
|
+
if (!action) return false;
|
|
153
|
+
|
|
154
|
+
const fromPayload = parseHubPayload(action.payload);
|
|
155
|
+
if (!fromPayload) return false;
|
|
156
|
+
|
|
157
|
+
return handleHubChoice(root, message, fromPayload.scopeId, fromPayload.choiceId);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let hubUiMounted = false;
|
|
161
|
+
|
|
162
|
+
export function isGameHubMounted(): boolean {
|
|
163
|
+
return hubUiMounted;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function markGameHubUiMounted(): void {
|
|
167
|
+
hubUiMounted = true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function resetGameHubMountForTests(): void {
|
|
171
|
+
hubUiMounted = false;
|
|
172
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getActionFromMessage, resolvePayloadFromText, type Message } from '@zhin.js/core';
|
|
2
|
+
|
|
3
|
+
import { channelKey } from './board-sender.js';
|
|
4
|
+
import { parseChoicePayload } from './choice-keyboard.js';
|
|
5
|
+
|
|
6
|
+
export const GAME_RESTART_CHOICE_IDS = new Set([
|
|
7
|
+
'restart',
|
|
8
|
+
'restart_char',
|
|
9
|
+
'restart_idiom',
|
|
10
|
+
'restart_bot',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export function isRestartChoice(choiceId: string): boolean {
|
|
14
|
+
return GAME_RESTART_CHOICE_IDS.has(choiceId) || choiceId.startsWith('restart_');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 从 QQ 指令预填 / 数字 fallback 解析游戏 payload */
|
|
18
|
+
export function resolveGameTextPayload(
|
|
19
|
+
raw: string,
|
|
20
|
+
map?: Record<string, string>,
|
|
21
|
+
): string | undefined {
|
|
22
|
+
return resolvePayloadFromText(raw, map);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GameSessionRef {
|
|
26
|
+
id: string;
|
|
27
|
+
channel_key: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolveGameChoiceOptions {
|
|
31
|
+
message: Message<any>;
|
|
32
|
+
gamePrefix: string;
|
|
33
|
+
validChoiceIds: readonly string[];
|
|
34
|
+
getById: (sessionId: string) => Promise<GameSessionRef | null>;
|
|
35
|
+
getActiveForUser: (channel: string, userId: string) => Promise<GameSessionRef | null>;
|
|
36
|
+
getByBoardMessageId?: (messageId: string) => Promise<GameSessionRef | null>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 解析按钮/选项回调:优先 payload;终局「再来一局」允许已结束 session。
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveGameChoice(
|
|
43
|
+
opts: ResolveGameChoiceOptions,
|
|
44
|
+
): Promise<{ sessionId: string; choiceId: string } | null> {
|
|
45
|
+
const action = getActionFromMessage(opts.message);
|
|
46
|
+
if (!action) return null;
|
|
47
|
+
|
|
48
|
+
const ch = channelKey(opts.message);
|
|
49
|
+
const userId = opts.message.$sender.id;
|
|
50
|
+
|
|
51
|
+
const fromPayload = parseChoicePayload(action.payload, opts.gamePrefix);
|
|
52
|
+
if (fromPayload && opts.validChoiceIds.includes(fromPayload.choiceId)) {
|
|
53
|
+
const session = await opts.getById(fromPayload.sessionId);
|
|
54
|
+
if (session?.channel_key === ch) {
|
|
55
|
+
return { sessionId: fromPayload.sessionId, choiceId: fromPayload.choiceId };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const choiceId = action.id || action.payload;
|
|
60
|
+
if (!choiceId || !opts.validChoiceIds.includes(choiceId)) return null;
|
|
61
|
+
|
|
62
|
+
if (isRestartChoice(choiceId)) {
|
|
63
|
+
if (fromPayload) {
|
|
64
|
+
const session = await opts.getById(fromPayload.sessionId);
|
|
65
|
+
if (session?.channel_key === ch) {
|
|
66
|
+
return { sessionId: fromPayload.sessionId, choiceId };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (action.sourceMessageId && opts.getByBoardMessageId) {
|
|
70
|
+
const byBoard = await opts.getByBoardMessageId(action.sourceMessageId);
|
|
71
|
+
if (byBoard && byBoard.channel_key === ch) {
|
|
72
|
+
return { sessionId: byBoard.id, choiceId };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const active = await opts.getActiveForUser(ch, userId);
|
|
76
|
+
if (active) return { sessionId: active.id, choiceId };
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const session =
|
|
81
|
+
(await opts.getActiveForUser(ch, userId))
|
|
82
|
+
?? (action.sourceMessageId && opts.getByBoardMessageId
|
|
83
|
+
? await opts.getByBoardMessageId(action.sourceMessageId)
|
|
84
|
+
: null);
|
|
85
|
+
if (!session || session.channel_key !== ch) return null;
|
|
86
|
+
|
|
87
|
+
return { sessionId: session.id, choiceId };
|
|
88
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { MessageMiddleware, Plugin, RegisteredAdapter } from '@zhin.js/core';
|
|
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,67 @@
|
|
|
1
|
+
import { getActionFromMessage, type Message, type Plugin } from '@zhin.js/core';
|
|
2
|
+
|
|
3
|
+
const hintedChannels = new Set<string>();
|
|
4
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
const channelHintAt = new Map<string, number>();
|
|
6
|
+
|
|
7
|
+
function channelHintKey(message: Message<any>): string {
|
|
8
|
+
return `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function pruneHintCache(): void {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
for (const [key, at] of channelHintAt) {
|
|
14
|
+
if (now - at > TTL_MS) {
|
|
15
|
+
channelHintAt.delete(key);
|
|
16
|
+
hintedChannels.delete(key);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function messageMentionsBot(message: Message<any>): boolean {
|
|
22
|
+
const raw = message.$raw ?? '';
|
|
23
|
+
if (/@(?:everyone|all)/i.test(raw)) return false;
|
|
24
|
+
const botId = message.$bot?.id;
|
|
25
|
+
if (botId && raw.includes(String(botId))) return true;
|
|
26
|
+
for (const item of message.$content ?? []) {
|
|
27
|
+
if (typeof item === 'string') continue;
|
|
28
|
+
if (item.type === 'at' && (!item.data.user_id || item.data.user_id === botId || item.data.user_id === 'all')) {
|
|
29
|
+
if (item.data.user_id !== 'all') return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function looksLikeCommand(raw: string): boolean {
|
|
36
|
+
const t = raw.trim();
|
|
37
|
+
return t.startsWith('/') || /^(游戏|game|帮助|help|签到|stats)\b/i.test(t);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 群聊首次 @ 且非命令时提示 /游戏 /帮助(每频道 24h 最多一次)
|
|
42
|
+
*/
|
|
43
|
+
export function mountFirstAtHintMiddleware(root: Plugin): () => void {
|
|
44
|
+
return root.addMiddleware(async (message, next) => {
|
|
45
|
+
if (message.$channel.type !== 'group') return next();
|
|
46
|
+
if (getActionFromMessage(message)) return next();
|
|
47
|
+
if (!messageMentionsBot(message)) return next();
|
|
48
|
+
|
|
49
|
+
const raw = message.$raw?.trim() ?? '';
|
|
50
|
+
if (looksLikeCommand(raw)) return next();
|
|
51
|
+
|
|
52
|
+
pruneHintCache();
|
|
53
|
+
const key = channelHintKey(message);
|
|
54
|
+
if (hintedChannels.has(key)) return next();
|
|
55
|
+
|
|
56
|
+
hintedChannels.add(key);
|
|
57
|
+
channelHintAt.set(key, Date.now());
|
|
58
|
+
|
|
59
|
+
return next();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 测试专用 */
|
|
64
|
+
export function resetOnboardingForTests(): void {
|
|
65
|
+
hintedChannels.clear();
|
|
66
|
+
channelHintAt.clear();
|
|
67
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MessageCommand,
|
|
3
|
+
type Database,
|
|
4
|
+
type DatabaseFeature,
|
|
5
|
+
type Message,
|
|
6
|
+
type Models,
|
|
7
|
+
type Plugin,
|
|
8
|
+
type RelatedModel,
|
|
9
|
+
} from '@zhin.js/core';
|
|
10
|
+
import { channelKey } from './board-sender.js';
|
|
11
|
+
import { getRegisteredGame, getRegisteredGames } from './game-hub-feature.js';
|
|
12
|
+
import { createHostGameDb, type HostGameDbSource } from './memory-db.js';
|
|
13
|
+
import { generateCompactId } from './random.js';
|
|
14
|
+
|
|
15
|
+
export type GameRecordResult = 'won' | 'lost' | 'draw' | 'aborted';
|
|
16
|
+
|
|
17
|
+
declare module '@zhin.js/core' {
|
|
18
|
+
interface Models {
|
|
19
|
+
game_records: {
|
|
20
|
+
id: string;
|
|
21
|
+
user_id: string;
|
|
22
|
+
user_name: string;
|
|
23
|
+
channel_key: string;
|
|
24
|
+
game_id: string;
|
|
25
|
+
result: GameRecordResult;
|
|
26
|
+
score: number;
|
|
27
|
+
created_at: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type GameRecordRow = Models['game_records'];
|
|
33
|
+
type GameRecordDb = Database<unknown, Models, string>;
|
|
34
|
+
|
|
35
|
+
let legacyDb: GameRecordDb | null = null;
|
|
36
|
+
let hostDatabases = new WeakMap<GameRecordDatabaseHost, GameRecordDb>();
|
|
37
|
+
const hostRegistrations: Array<{
|
|
38
|
+
readonly host: GameRecordDatabaseHost;
|
|
39
|
+
readonly database: GameRecordDb;
|
|
40
|
+
}> = [];
|
|
41
|
+
|
|
42
|
+
function activeDatabase(): GameRecordDb | null {
|
|
43
|
+
return hostRegistrations[hostRegistrations.length - 1]?.database ?? legacyDb;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getRecordModel(database: GameRecordDb): RelatedModel<unknown, Models, 'game_records'> {
|
|
47
|
+
const model = database.models.get('game_records');
|
|
48
|
+
if (!model) throw new Error('game_records not registered');
|
|
49
|
+
return model as RelatedModel<unknown, Models, 'game_records'>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function registerGameRecordModels(plugin: Plugin): void {
|
|
53
|
+
plugin.defineModel('game_records', {
|
|
54
|
+
id: { type: 'text', primary: true },
|
|
55
|
+
user_id: { type: 'text', nullable: false },
|
|
56
|
+
user_name: { type: 'text', default: '' },
|
|
57
|
+
channel_key: { type: 'text', nullable: false },
|
|
58
|
+
game_id: { type: 'text', nullable: false },
|
|
59
|
+
result: { type: 'text', nullable: false },
|
|
60
|
+
score: { type: 'integer', default: 0 },
|
|
61
|
+
created_at: { type: 'integer', default: 0 },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function initGameRecordDatabase(dbFeature: DatabaseFeature): void {
|
|
66
|
+
legacyDb = dbFeature.db as GameRecordDb;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const GAME_RECORDS_DEFINITION: Record<string, unknown> = {
|
|
70
|
+
id: { type: 'text', primary: true },
|
|
71
|
+
user_id: { type: 'text', nullable: false },
|
|
72
|
+
user_name: { type: 'text', default: '' },
|
|
73
|
+
channel_key: { type: 'text', nullable: false },
|
|
74
|
+
game_id: { type: 'text', nullable: false },
|
|
75
|
+
result: { type: 'text', nullable: false },
|
|
76
|
+
score: { type: 'integer', default: 0 },
|
|
77
|
+
created_at: { type: 'integer', default: 0 },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Plugin Runtime DatabaseHost 的最小结构(与 @zhin.js/plugin-runtime 的 DatabaseHost 结构对齐) */
|
|
81
|
+
export interface GameRecordDatabaseHost extends HostGameDbSource {
|
|
82
|
+
define(name: string, definition: Record<string, unknown>): void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Plugin Runtime 下用 databaseHostToken 初始化战绩库。
|
|
87
|
+
* 幂等:多个游戏插件 setup 都会调用,只建一次;无 token 时保持跳过(内存/静默)。
|
|
88
|
+
*/
|
|
89
|
+
export function initGameRecordHost(host: GameRecordDatabaseHost): () => void {
|
|
90
|
+
let database = hostDatabases.get(host);
|
|
91
|
+
if (!database) {
|
|
92
|
+
host.define('game_records', GAME_RECORDS_DEFINITION);
|
|
93
|
+
database = createHostGameDb(host, ['game_records']) as unknown as GameRecordDb;
|
|
94
|
+
hostDatabases.set(host, database);
|
|
95
|
+
}
|
|
96
|
+
const registration = Object.freeze({ host, database });
|
|
97
|
+
hostRegistrations.push(registration);
|
|
98
|
+
return () => {
|
|
99
|
+
const index = hostRegistrations.lastIndexOf(registration);
|
|
100
|
+
if (index >= 0) hostRegistrations.splice(index, 1);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function recordId(): string {
|
|
105
|
+
return generateCompactId('gr');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 对局结束时写入战绩(database 未就绪时静默跳过) */
|
|
109
|
+
export async function recordGameOutcome(
|
|
110
|
+
message: Message<any>,
|
|
111
|
+
gameId: string,
|
|
112
|
+
result: GameRecordResult,
|
|
113
|
+
score = 0,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const db = activeDatabase();
|
|
116
|
+
if (!db) return;
|
|
117
|
+
const row: GameRecordRow = {
|
|
118
|
+
id: recordId(),
|
|
119
|
+
user_id: message.$sender.id,
|
|
120
|
+
user_name: String(message.$sender.name ?? message.$sender.id),
|
|
121
|
+
channel_key: channelKey(message),
|
|
122
|
+
game_id: gameId,
|
|
123
|
+
result,
|
|
124
|
+
score,
|
|
125
|
+
created_at: Date.now(),
|
|
126
|
+
};
|
|
127
|
+
await getRecordModel(db).create(row);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface UserGameStats {
|
|
131
|
+
gameId: string;
|
|
132
|
+
wins: number;
|
|
133
|
+
losses: number;
|
|
134
|
+
draws: number;
|
|
135
|
+
totalScore: number;
|
|
136
|
+
games: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function getUserGameStats(
|
|
140
|
+
userId: string,
|
|
141
|
+
channelKeyFilter?: string,
|
|
142
|
+
): Promise<UserGameStats[]> {
|
|
143
|
+
const db = activeDatabase();
|
|
144
|
+
if (!db) return [];
|
|
145
|
+
const where = channelKeyFilter
|
|
146
|
+
? { user_id: userId, channel_key: channelKeyFilter }
|
|
147
|
+
: { user_id: userId };
|
|
148
|
+
const rows = await getRecordModel(db).findAll(where);
|
|
149
|
+
const byGame = new Map<string, UserGameStats>();
|
|
150
|
+
for (const row of rows) {
|
|
151
|
+
let stat = byGame.get(row.game_id);
|
|
152
|
+
if (!stat) {
|
|
153
|
+
stat = {
|
|
154
|
+
gameId: row.game_id,
|
|
155
|
+
wins: 0,
|
|
156
|
+
losses: 0,
|
|
157
|
+
draws: 0,
|
|
158
|
+
totalScore: 0,
|
|
159
|
+
games: 0,
|
|
160
|
+
};
|
|
161
|
+
byGame.set(row.game_id, stat);
|
|
162
|
+
}
|
|
163
|
+
stat.games++;
|
|
164
|
+
stat.totalScore += row.score;
|
|
165
|
+
if (row.result === 'won') stat.wins++;
|
|
166
|
+
else if (row.result === 'lost') stat.losses++;
|
|
167
|
+
else if (row.result === 'draw') stat.draws++;
|
|
168
|
+
}
|
|
169
|
+
return [...byGame.values()].sort((a, b) => b.wins - a.wins);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface LeaderboardEntry {
|
|
173
|
+
userId: string;
|
|
174
|
+
userName: string;
|
|
175
|
+
wins: number;
|
|
176
|
+
totalScore: number;
|
|
177
|
+
games: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function getGameLeaderboard(
|
|
181
|
+
gameId: string,
|
|
182
|
+
channelKeyFilter: string,
|
|
183
|
+
limit = 10,
|
|
184
|
+
): Promise<LeaderboardEntry[]> {
|
|
185
|
+
const db = activeDatabase();
|
|
186
|
+
if (!db) return [];
|
|
187
|
+
const rows = await getRecordModel(db).findAll({
|
|
188
|
+
game_id: gameId,
|
|
189
|
+
channel_key: channelKeyFilter,
|
|
190
|
+
});
|
|
191
|
+
const byUser = new Map<string, LeaderboardEntry>();
|
|
192
|
+
for (const row of rows) {
|
|
193
|
+
let entry = byUser.get(row.user_id);
|
|
194
|
+
if (!entry) {
|
|
195
|
+
entry = {
|
|
196
|
+
userId: row.user_id,
|
|
197
|
+
userName: row.user_name,
|
|
198
|
+
wins: 0,
|
|
199
|
+
totalScore: 0,
|
|
200
|
+
games: 0,
|
|
201
|
+
};
|
|
202
|
+
byUser.set(row.user_id, entry);
|
|
203
|
+
}
|
|
204
|
+
entry.games++;
|
|
205
|
+
entry.totalScore += row.score;
|
|
206
|
+
if (row.result === 'won') entry.wins++;
|
|
207
|
+
}
|
|
208
|
+
return [...byUser.values()]
|
|
209
|
+
.sort((a, b) => b.wins - a.wins || b.totalScore - a.totalScore)
|
|
210
|
+
.slice(0, limit);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function gameTitle(gameId: string): string {
|
|
214
|
+
const g = getRegisteredGame(gameId);
|
|
215
|
+
return g ? `${g.icon} ${g.title}` : gameId;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function formatUserStats(stats: UserGameStats[]): string {
|
|
219
|
+
if (!stats.length) return '暂无战绩记录,快去 `/游戏` 开一局吧!';
|
|
220
|
+
const lines = stats.map((s) => {
|
|
221
|
+
const title = gameTitle(s.gameId);
|
|
222
|
+
return `• ${title}:${s.wins} 胜 ${s.losses} 负${s.draws ? ` ${s.draws} 平` : ''}(${s.games} 局,得分 ${s.totalScore})`;
|
|
223
|
+
});
|
|
224
|
+
return ['📊 **你的战绩**', '', ...lines].join('\n');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function formatLeaderboard(gameId: string, entries: LeaderboardEntry[]): string {
|
|
228
|
+
const title = gameTitle(gameId);
|
|
229
|
+
if (!entries.length) {
|
|
230
|
+
return `${title} 在本群暂无排行,发送 \`/游戏\` 开始第一局!`;
|
|
231
|
+
}
|
|
232
|
+
const lines = entries.map((e, i) =>
|
|
233
|
+
`${i + 1}. ${e.userName} — ${e.wins} 胜 / ${e.games} 局(得分 ${e.totalScore})`,
|
|
234
|
+
);
|
|
235
|
+
return [`🏆 **${title} 本群排行**`, '', ...lines].join('\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function mountGameRecordCommands(root: Plugin): () => void {
|
|
239
|
+
const disposers: (() => void)[] = [];
|
|
240
|
+
|
|
241
|
+
disposers.push(
|
|
242
|
+
root.addCommand(
|
|
243
|
+
new MessageCommand('/战绩')
|
|
244
|
+
.desc('查看本人在本群的游戏战绩')
|
|
245
|
+
.action(async (message) => {
|
|
246
|
+
const stats = await getUserGameStats(message.$sender.id, channelKey(message));
|
|
247
|
+
return formatUserStats(stats);
|
|
248
|
+
}),
|
|
249
|
+
),
|
|
250
|
+
root.addCommand(
|
|
251
|
+
new MessageCommand('/排行')
|
|
252
|
+
.desc('查看本群某游戏排行榜')
|
|
253
|
+
.action(async (message, ...args) => {
|
|
254
|
+
const query = args.join(' ').trim();
|
|
255
|
+
const games = getRegisteredGames();
|
|
256
|
+
if (!games.length) return '暂无已注册游戏。';
|
|
257
|
+
let gameId = games[0]!.id;
|
|
258
|
+
if (query) {
|
|
259
|
+
const hit = games.find(
|
|
260
|
+
(g) => g.id === query
|
|
261
|
+
|| g.title.includes(query)
|
|
262
|
+
|| g.commandPrefix.includes(query)
|
|
263
|
+
|| g.aliases?.some((a) => a.includes(query)),
|
|
264
|
+
);
|
|
265
|
+
if (!hit) {
|
|
266
|
+
const names = games.map((g) => g.title).join('、');
|
|
267
|
+
return `未找到游戏「${query}」。可选:${names}`;
|
|
268
|
+
}
|
|
269
|
+
gameId = hit.id;
|
|
270
|
+
}
|
|
271
|
+
const board = await getGameLeaderboard(gameId, channelKey(message));
|
|
272
|
+
return formatLeaderboard(gameId, board);
|
|
273
|
+
}),
|
|
274
|
+
),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
return () => {
|
|
278
|
+
for (const d of disposers) d();
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 测试专用 */
|
|
283
|
+
export function resetGameRecordsForTests(): void {
|
|
284
|
+
legacyDb = null;
|
|
285
|
+
hostDatabases = new WeakMap();
|
|
286
|
+
hostRegistrations.splice(0);
|
|
287
|
+
}
|