@zhin.js/game-kit 1.0.2 → 2.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/lib/board-sender.d.ts +6 -7
- package/lib/board-sender.d.ts.map +1 -1
- package/lib/board-sender.js.map +1 -1
- package/lib/choice-keyboard.d.ts +3 -1
- package/lib/choice-keyboard.d.ts.map +1 -1
- package/lib/choice-keyboard.js +8 -4
- package/lib/choice-keyboard.js.map +1 -1
- package/lib/command-help.d.ts +1 -1
- package/lib/command-help.d.ts.map +1 -1
- package/lib/command-help.js.map +1 -1
- package/lib/command-message.d.ts +1 -1
- package/lib/command-message.d.ts.map +1 -1
- package/lib/command-message.js.map +1 -1
- package/lib/game-action-alias.d.ts +7 -0
- package/lib/game-action-alias.d.ts.map +1 -1
- package/lib/game-action-alias.js +25 -0
- package/lib/game-action-alias.js.map +1 -1
- package/lib/game-hub-feature.d.ts +3 -3
- package/lib/game-hub-feature.d.ts.map +1 -1
- package/lib/game-hub-feature.js +9 -7
- package/lib/game-hub-feature.js.map +1 -1
- package/lib/game-hub-flow.d.ts +2 -2
- package/lib/game-hub-flow.d.ts.map +1 -1
- package/lib/game-hub-flow.js.map +1 -1
- package/lib/game-hub-menu-context.d.ts +4 -4
- package/lib/game-hub-menu-context.d.ts.map +1 -1
- package/lib/game-hub-menu-context.js.map +1 -1
- package/lib/game-hub-mount.d.ts +4 -0
- package/lib/game-hub-mount.d.ts.map +1 -1
- package/lib/game-hub-mount.js +8 -27
- package/lib/game-hub-mount.js.map +1 -1
- package/lib/game-interactive.d.ts +1 -1
- package/lib/game-interactive.d.ts.map +1 -1
- package/lib/game-onboarding.d.ts.map +1 -1
- package/lib/game-onboarding.js +1 -0
- package/lib/game-onboarding.js.map +1 -1
- package/lib/game-records.d.ts +8 -2
- package/lib/game-records.d.ts.map +1 -1
- package/lib/game-records.js +31 -3
- package/lib/game-records.js.map +1 -1
- package/lib/game-session.d.ts +1 -1
- package/lib/game-session.d.ts.map +1 -1
- package/lib/game-session.js +2 -1
- package/lib/game-session.js.map +1 -1
- package/lib/grid-keyboard.d.ts.map +1 -1
- package/lib/grid-keyboard.js +16 -6
- package/lib/grid-keyboard.js.map +1 -1
- package/lib/memory-db.d.ts +1 -1
- package/lib/memory-db.d.ts.map +1 -1
- package/lib/memory-db.js +8 -3
- package/lib/memory-db.js.map +1 -1
- package/lib/random.d.ts.map +1 -1
- package/lib/random.js +10 -2
- package/lib/random.js.map +1 -1
- package/lib/runtime-hub.d.ts +0 -4
- package/lib/runtime-hub.d.ts.map +1 -1
- package/lib/runtime-hub.js +8 -0
- package/lib/runtime-hub.js.map +1 -1
- package/package.json +7 -7
- package/src/board-sender.ts +6 -7
- package/src/choice-keyboard.ts +6 -3
- package/src/command-help.ts +1 -1
- package/src/command-message.ts +5 -1
- package/src/game-action-alias.ts +33 -0
- package/src/game-hub-feature.ts +12 -9
- package/src/game-hub-flow.ts +2 -2
- package/src/game-hub-menu-context.ts +5 -5
- package/src/game-hub-mount.ts +10 -35
- package/src/game-interactive.ts +1 -1
- package/src/game-onboarding.ts +6 -2
- package/src/game-records.ts +33 -5
- package/src/game-session.ts +3 -2
- package/src/grid-keyboard.ts +15 -7
- package/src/memory-db.ts +11 -4
- package/src/random.ts +10 -2
- package/src/runtime-hub.ts +5 -0
package/src/game-action-alias.ts
CHANGED
|
@@ -72,22 +72,47 @@ export function normalizeTttAction(raw: string): string {
|
|
|
72
72
|
return lookup(TTT_ACTIONS, raw);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** 判断 normalize*Action 的结果是否为已知 action(未知名应放行,避免劫持普通聊天) */
|
|
76
|
+
function isKnownAction(map: Record<string, string>, action: string): boolean {
|
|
77
|
+
return Object.values(map).includes(action);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isTttAction(action: string): boolean {
|
|
81
|
+
return isKnownAction(TTT_ACTIONS, action);
|
|
82
|
+
}
|
|
83
|
+
|
|
75
84
|
export function normalizeAdvAction(raw: string): string {
|
|
76
85
|
return lookup(ADV_ACTIONS, raw);
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
export function isAdvAction(action: string): boolean {
|
|
89
|
+
return isKnownAction(ADV_ACTIONS, action);
|
|
90
|
+
}
|
|
91
|
+
|
|
79
92
|
export function normalizeRpsAction(raw: string): string {
|
|
80
93
|
return lookup(RPS_ACTIONS, raw);
|
|
81
94
|
}
|
|
82
95
|
|
|
96
|
+
export function isRpsAction(action: string): boolean {
|
|
97
|
+
return isKnownAction(RPS_ACTIONS, action);
|
|
98
|
+
}
|
|
99
|
+
|
|
83
100
|
export function normalizeGuessAction(raw: string): string {
|
|
84
101
|
return lookup(GUESS_ACTIONS, raw);
|
|
85
102
|
}
|
|
86
103
|
|
|
104
|
+
export function isGuessAction(action: string): boolean {
|
|
105
|
+
return isKnownAction(GUESS_ACTIONS, action);
|
|
106
|
+
}
|
|
107
|
+
|
|
87
108
|
export function normalizeDiceAction(raw: string): string {
|
|
88
109
|
return lookup(DICE_ACTIONS, raw);
|
|
89
110
|
}
|
|
90
111
|
|
|
112
|
+
export function isDiceAction(action: string): boolean {
|
|
113
|
+
return isKnownAction(DICE_ACTIONS, action);
|
|
114
|
+
}
|
|
115
|
+
|
|
91
116
|
const CHAIN_ACTIONS: Record<string, string> = {
|
|
92
117
|
start: 'start_pinyin',
|
|
93
118
|
start_pinyin: 'start_pinyin',
|
|
@@ -124,6 +149,14 @@ export function normalizeChainAction(raw: string): string {
|
|
|
124
149
|
return lookup(CHAIN_ACTIONS, raw);
|
|
125
150
|
}
|
|
126
151
|
|
|
152
|
+
export function isChainAction(action: string): boolean {
|
|
153
|
+
return isKnownAction(CHAIN_ACTIONS, action);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function isRiddleAction(action: string): boolean {
|
|
157
|
+
return isKnownAction(RIDDLE_ACTIONS, action);
|
|
158
|
+
}
|
|
159
|
+
|
|
127
160
|
export function normalizeRiddleAction(raw: string): string {
|
|
128
161
|
return lookup(RIDDLE_ACTIONS, raw);
|
|
129
162
|
}
|
package/src/game-hub-feature.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { Feature, getPlugin, type FeatureJSON, type Plugin, type PluginLike, type DatabaseFeature } from '@zhin.js/core';
|
|
1
|
+
import { Feature, getPlugin, type FeatureJSON, type Message, type Plugin, type PluginLike, type DatabaseFeature } from '@zhin.js/core';
|
|
2
2
|
import { mountGameHubUi, markGameHubUiMounted } from './game-hub-mount.js';
|
|
3
3
|
import { registerGameRecordModels, initGameRecordDatabase } from './game-records.js';
|
|
4
4
|
|
|
5
5
|
export interface GameHubContext {
|
|
6
6
|
plugin: Plugin;
|
|
7
|
-
message:
|
|
7
|
+
message: Message;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export interface GameMenuAction {
|
|
@@ -42,6 +42,10 @@ declare module '@zhin.js/core' {
|
|
|
42
42
|
|
|
43
43
|
let activeFeature: GameHubFeature | null = null;
|
|
44
44
|
|
|
45
|
+
function setActiveFeature(feature: GameHubFeature | null): void {
|
|
46
|
+
activeFeature = feature;
|
|
47
|
+
}
|
|
48
|
+
|
|
45
49
|
export class GameHubFeature extends Feature<RegisteredGame> {
|
|
46
50
|
readonly name = 'game' as const;
|
|
47
51
|
readonly icon = 'Gamepad2';
|
|
@@ -84,13 +88,12 @@ export class GameHubFeature extends Feature<RegisteredGame> {
|
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
get extensions() {
|
|
87
|
-
const feature = this;
|
|
88
91
|
return {
|
|
89
|
-
registerGame(game: RegisteredGame) {
|
|
92
|
+
registerGame: (game: RegisteredGame) => {
|
|
90
93
|
const plugin = getPlugin();
|
|
91
94
|
ensureGameHubService(plugin);
|
|
92
|
-
const dispose =
|
|
93
|
-
plugin.recordFeatureContribution(
|
|
95
|
+
const dispose = this.register(game, plugin.name);
|
|
96
|
+
plugin.recordFeatureContribution(this.name, game.id);
|
|
94
97
|
plugin.onDispose(dispose);
|
|
95
98
|
return dispose;
|
|
96
99
|
},
|
|
@@ -98,7 +101,7 @@ export class GameHubFeature extends Feature<RegisteredGame> {
|
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
mounted(plugin: PluginLike): void {
|
|
101
|
-
|
|
104
|
+
setActiveFeature(this);
|
|
102
105
|
const root = plugin as Plugin;
|
|
103
106
|
const host = root.root ?? root;
|
|
104
107
|
registerGameRecordModels(host);
|
|
@@ -117,7 +120,7 @@ export class GameHubFeature extends Feature<RegisteredGame> {
|
|
|
117
120
|
for (const d of this.hubDisposers) d();
|
|
118
121
|
this.hubDisposers = [];
|
|
119
122
|
if (activeFeature === this) {
|
|
120
|
-
|
|
123
|
+
setActiveFeature(null);
|
|
121
124
|
}
|
|
122
125
|
}
|
|
123
126
|
}
|
|
@@ -133,7 +136,7 @@ export function ensureGameHubService(plugin: Plugin): GameHubFeature {
|
|
|
133
136
|
|
|
134
137
|
const feature = new GameHubFeature();
|
|
135
138
|
root.provide(feature);
|
|
136
|
-
|
|
139
|
+
setActiveFeature(feature);
|
|
137
140
|
|
|
138
141
|
if (root.started && feature.mounted) {
|
|
139
142
|
feature.mounted(root);
|
package/src/game-hub-flow.ts
CHANGED
|
@@ -42,7 +42,7 @@ function gameMenuChoices(gameId: string, channelType: string): HubMenuChoice[] {
|
|
|
42
42
|
];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export function openMainMenu(message: Message
|
|
45
|
+
export function openMainMenu(message: Message): ReturnType<typeof buildMainHubMenu> | string {
|
|
46
46
|
const games = getRegisteredGames();
|
|
47
47
|
if (!games.length) {
|
|
48
48
|
return formatHubEmptyMessage();
|
|
@@ -55,7 +55,7 @@ export function openMainMenu(message: Message<any>): ReturnType<typeof buildMain
|
|
|
55
55
|
|
|
56
56
|
export async function handleHubChoice(
|
|
57
57
|
plugin: Plugin,
|
|
58
|
-
message: Message
|
|
58
|
+
message: Message,
|
|
59
59
|
scopeId: string,
|
|
60
60
|
choiceId: string,
|
|
61
61
|
): Promise<boolean> {
|
|
@@ -6,7 +6,7 @@ export interface HubMenuContext {
|
|
|
6
6
|
channelKey: string;
|
|
7
7
|
/** 打开菜单的用户(仅记录,大厅导航不限制点击者) */
|
|
8
8
|
openerId: string;
|
|
9
|
-
message: Message
|
|
9
|
+
message: Message;
|
|
10
10
|
expiresAt: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -27,11 +27,11 @@ const contexts = new Map<string, HubMenuContext>();
|
|
|
27
27
|
const lastMenus = new Map<string, LastHubMenu>();
|
|
28
28
|
|
|
29
29
|
/** 大厅 fallback 按频道共享,任意成员可回复数字或点按钮 */
|
|
30
|
-
function hubMenuKey(message: Message
|
|
30
|
+
function hubMenuKey(message: Message): string {
|
|
31
31
|
return channelKey(message);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export function createHubScope(message: Message
|
|
34
|
+
export function createHubScope(message: Message): string {
|
|
35
35
|
const scopeId = generateCompactId('h');
|
|
36
36
|
contexts.set(scopeId, {
|
|
37
37
|
channelKey: channelKey(message),
|
|
@@ -44,7 +44,7 @@ export function createHubScope(message: Message<any>): string {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export function rememberHubMenu(
|
|
47
|
-
message: Message
|
|
47
|
+
message: Message,
|
|
48
48
|
scopeId: string,
|
|
49
49
|
choices: HubMenuChoice[],
|
|
50
50
|
): void {
|
|
@@ -56,7 +56,7 @@ export function rememberHubMenu(
|
|
|
56
56
|
pruneExpired();
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export function getLastHubMenu(message: Message
|
|
59
|
+
export function getLastHubMenu(message: Message): LastHubMenu | null {
|
|
60
60
|
pruneExpired();
|
|
61
61
|
const key = hubMenuKey(message);
|
|
62
62
|
const last = lastMenus.get(key);
|
package/src/game-hub-mount.ts
CHANGED
|
@@ -2,17 +2,14 @@ import {
|
|
|
2
2
|
Message,
|
|
3
3
|
MessageCommand,
|
|
4
4
|
getActionFromMessage,
|
|
5
|
-
resolvePayloadFromText,
|
|
6
5
|
type Plugin,
|
|
7
6
|
} from '@zhin.js/core';
|
|
8
|
-
import { buildChoiceFallbackMap, parseChoicePayload } from './choice-keyboard.js';
|
|
9
7
|
import {
|
|
10
8
|
formatHubEmptyMessage,
|
|
11
9
|
buildBotHelpReply,
|
|
12
10
|
buildHubHelpReply,
|
|
13
11
|
HUB_PREFIX,
|
|
14
12
|
} from './game-hub-menu.js';
|
|
15
|
-
import { getLastHubMenu } from './game-hub-menu-context.js';
|
|
16
13
|
import { handleHubChoice, openMainMenu, parseHubPayload } from './game-hub-flow.js';
|
|
17
14
|
import { getRegisteredGames } from './game-hub-feature.js';
|
|
18
15
|
import { mountGameRecordCommands } from './game-records.js';
|
|
@@ -24,17 +21,21 @@ function resolveRegisteredCommands(root: Plugin): MessageCommand[] {
|
|
|
24
21
|
return commandService?.items ?? [];
|
|
25
22
|
}
|
|
26
23
|
|
|
27
|
-
async function resolveHelpCommands(root: Plugin, message: Message
|
|
24
|
+
async function resolveHelpCommands(root: Plugin, message: Message): Promise<CommandHelpSource[]> {
|
|
28
25
|
return filterHelpCommands(resolveRegisteredCommands(root), message, root);
|
|
29
26
|
}
|
|
30
27
|
|
|
31
28
|
/**
|
|
32
29
|
* 在 root 上挂载「游戏 / game」大厅命令,返回 dispose 列表。
|
|
30
|
+
*
|
|
31
|
+
* @deprecated 仅 classic `GameHubFeature` / `bootstrapNode` 路径。
|
|
32
|
+
* Plugin Runtime 请用 `@zhin.js/plugin-game-hub` 的 `commands/games/`(defineCommand)。
|
|
33
|
+
* 勿在新代码调用;随 classic Feature 退役。
|
|
33
34
|
*/
|
|
34
35
|
export function mountGameHubUi(root: Plugin): (() => void)[] {
|
|
35
36
|
const disposers: (() => void)[] = [];
|
|
36
37
|
|
|
37
|
-
const openHandler = async (message: Message
|
|
38
|
+
const openHandler = async (message: Message) => {
|
|
38
39
|
const games = getRegisteredGames();
|
|
39
40
|
if (!games.length) {
|
|
40
41
|
return formatHubEmptyMessage();
|
|
@@ -111,43 +112,17 @@ export function mountGameHubUi(root: Plugin): (() => void)[] {
|
|
|
111
112
|
disposers.push(mountGameRecordCommands(root));
|
|
112
113
|
disposers.push(mountFirstAtHintMiddleware(root));
|
|
113
114
|
|
|
115
|
+
// interactive 回跳由框架中央执行(`registerInteractiveHandler` 安装的根
|
|
116
|
+
// 中间件):action 段、数字 fallback(中央存储的菜单映射)与 QQ 指令预填
|
|
117
|
+
// 直出 payload 都路由到下面的 hub handler,不再自行解析文本。
|
|
114
118
|
root.registerInteractiveHandler(`${HUB_PREFIX}:`, (message) =>
|
|
115
119
|
handleHubInteractive(root, message),
|
|
116
120
|
);
|
|
117
121
|
|
|
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
122
|
return disposers;
|
|
148
123
|
}
|
|
149
124
|
|
|
150
|
-
async function handleHubInteractive(root: Plugin, message: Message
|
|
125
|
+
async function handleHubInteractive(root: Plugin, message: Message): Promise<boolean> {
|
|
151
126
|
const action = getActionFromMessage(message);
|
|
152
127
|
if (!action) return false;
|
|
153
128
|
|
package/src/game-interactive.ts
CHANGED
|
@@ -28,7 +28,7 @@ export interface GameSessionRef {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export interface ResolveGameChoiceOptions {
|
|
31
|
-
message: Message
|
|
31
|
+
message: Message;
|
|
32
32
|
gamePrefix: string;
|
|
33
33
|
validChoiceIds: readonly string[];
|
|
34
34
|
getById: (sessionId: string) => Promise<GameSessionRef | null>;
|
package/src/game-onboarding.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { getActionFromMessage, type Message, type Plugin } from '@zhin.js/core';
|
|
2
2
|
|
|
3
|
+
type BotAwareMessage = Message<{ readonly $bot?: { readonly id?: string } }>;
|
|
4
|
+
|
|
3
5
|
const hintedChannels = new Set<string>();
|
|
4
6
|
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
5
7
|
const channelHintAt = new Map<string, number>();
|
|
6
8
|
|
|
7
|
-
function channelHintKey(message: Message
|
|
9
|
+
function channelHintKey(message: Message): string {
|
|
8
10
|
return `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
|
|
9
11
|
}
|
|
10
12
|
|
|
@@ -18,7 +20,7 @@ function pruneHintCache(): void {
|
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
function messageMentionsBot(message:
|
|
23
|
+
function messageMentionsBot(message: BotAwareMessage): boolean {
|
|
22
24
|
const raw = message.$raw ?? '';
|
|
23
25
|
if (/@(?:everyone|all)/i.test(raw)) return false;
|
|
24
26
|
const botId = message.$bot?.id;
|
|
@@ -56,6 +58,8 @@ export function mountFirstAtHintMiddleware(root: Plugin): () => void {
|
|
|
56
58
|
hintedChannels.add(key);
|
|
57
59
|
channelHintAt.set(key, Date.now());
|
|
58
60
|
|
|
61
|
+
await message.$reply?.('发送「游戏」查看游戏列表,发送「帮助」查看可用命令');
|
|
62
|
+
|
|
59
63
|
return next();
|
|
60
64
|
});
|
|
61
65
|
}
|
package/src/game-records.ts
CHANGED
|
@@ -2,12 +2,12 @@ import {
|
|
|
2
2
|
MessageCommand,
|
|
3
3
|
type Database,
|
|
4
4
|
type DatabaseFeature,
|
|
5
|
-
type Message,
|
|
6
5
|
type Models,
|
|
7
6
|
type Plugin,
|
|
8
7
|
type RelatedModel,
|
|
9
8
|
} from '@zhin.js/core';
|
|
10
9
|
import { channelKey } from './board-sender.js';
|
|
10
|
+
import type { GameMessageLike } from './command-message.js';
|
|
11
11
|
import { getRegisteredGame, getRegisteredGames } from './game-hub-feature.js';
|
|
12
12
|
import { createHostGameDb, type HostGameDbSource } from './memory-db.js';
|
|
13
13
|
import { generateCompactId } from './random.js';
|
|
@@ -39,10 +39,33 @@ const hostRegistrations: Array<{
|
|
|
39
39
|
readonly database: GameRecordDb;
|
|
40
40
|
}> = [];
|
|
41
41
|
|
|
42
|
+
/** gameId → 该游戏插件 setup 时注册的战绩库(按插件作用域取库,避免多 Host 时全局最后注册者胜) */
|
|
43
|
+
const gameDatabases = new Map<string, GameRecordDb>();
|
|
44
|
+
/** 最近一次 initGameRecordHost 注册的库;registerRuntimeGame 借此把游戏绑定到所属 Host 的库 */
|
|
45
|
+
let latestHostDatabase: GameRecordDb | null = null;
|
|
46
|
+
|
|
42
47
|
function activeDatabase(): GameRecordDb | null {
|
|
43
48
|
return hostRegistrations[hostRegistrations.length - 1]?.database ?? legacyDb;
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
/** 按游戏作用域取库:优先该游戏插件注册时绑定的 Host 库,回退到全局活跃库 */
|
|
52
|
+
function databaseForGame(gameId: string): GameRecordDb | null {
|
|
53
|
+
return gameDatabases.get(gameId) ?? activeDatabase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 由 registerRuntimeGame 在游戏注册时调用:把游戏绑定到当前插件刚通过 initGameRecordHost 注册的战绩库。
|
|
58
|
+
* 返回解绑函数(随游戏注销一并解绑)。
|
|
59
|
+
*/
|
|
60
|
+
export function bindGameRecordDatabase(gameId: string): () => void {
|
|
61
|
+
const db = latestHostDatabase;
|
|
62
|
+
if (!db) return () => {};
|
|
63
|
+
gameDatabases.set(gameId, db);
|
|
64
|
+
return () => {
|
|
65
|
+
if (gameDatabases.get(gameId) === db) gameDatabases.delete(gameId);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
46
69
|
function getRecordModel(database: GameRecordDb): RelatedModel<unknown, Models, 'game_records'> {
|
|
47
70
|
const model = database.models.get('game_records');
|
|
48
71
|
if (!model) throw new Error('game_records not registered');
|
|
@@ -93,6 +116,7 @@ export function initGameRecordHost(host: GameRecordDatabaseHost): () => void {
|
|
|
93
116
|
database = createHostGameDb(host, ['game_records']) as unknown as GameRecordDb;
|
|
94
117
|
hostDatabases.set(host, database);
|
|
95
118
|
}
|
|
119
|
+
latestHostDatabase = database;
|
|
96
120
|
const registration = Object.freeze({ host, database });
|
|
97
121
|
hostRegistrations.push(registration);
|
|
98
122
|
return () => {
|
|
@@ -107,12 +131,12 @@ function recordId(): string {
|
|
|
107
131
|
|
|
108
132
|
/** 对局结束时写入战绩(database 未就绪时静默跳过) */
|
|
109
133
|
export async function recordGameOutcome(
|
|
110
|
-
message:
|
|
134
|
+
message: GameMessageLike,
|
|
111
135
|
gameId: string,
|
|
112
136
|
result: GameRecordResult,
|
|
113
137
|
score = 0,
|
|
114
138
|
): Promise<void> {
|
|
115
|
-
const db =
|
|
139
|
+
const db = databaseForGame(gameId);
|
|
116
140
|
if (!db) return;
|
|
117
141
|
const row: GameRecordRow = {
|
|
118
142
|
id: recordId(),
|
|
@@ -182,7 +206,7 @@ export async function getGameLeaderboard(
|
|
|
182
206
|
channelKeyFilter: string,
|
|
183
207
|
limit = 10,
|
|
184
208
|
): Promise<LeaderboardEntry[]> {
|
|
185
|
-
const db =
|
|
209
|
+
const db = databaseForGame(gameId);
|
|
186
210
|
if (!db) return [];
|
|
187
211
|
const rows = await getRecordModel(db).findAll({
|
|
188
212
|
game_id: gameId,
|
|
@@ -254,7 +278,9 @@ export function mountGameRecordCommands(root: Plugin): () => void {
|
|
|
254
278
|
const query = args.join(' ').trim();
|
|
255
279
|
const games = getRegisteredGames();
|
|
256
280
|
if (!games.length) return '暂无已注册游戏。';
|
|
257
|
-
|
|
281
|
+
const firstGame = games[0];
|
|
282
|
+
if (!firstGame) return '暂无已注册游戏。';
|
|
283
|
+
let gameId = firstGame.id;
|
|
258
284
|
if (query) {
|
|
259
285
|
const hit = games.find(
|
|
260
286
|
(g) => g.id === query
|
|
@@ -284,4 +310,6 @@ export function resetGameRecordsForTests(): void {
|
|
|
284
310
|
legacyDb = null;
|
|
285
311
|
hostDatabases = new WeakMap();
|
|
286
312
|
hostRegistrations.splice(0);
|
|
313
|
+
gameDatabases.clear();
|
|
314
|
+
latestHostDatabase = null;
|
|
287
315
|
}
|
package/src/game-session.ts
CHANGED
|
@@ -83,7 +83,7 @@ export function nextTurn(current: 1 | 2): 1 | 2 {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/** 从 Message 提取发送者显示名 */
|
|
86
|
-
export function senderDisplayName(message: Message
|
|
86
|
+
export function senderDisplayName(message: Message): string {
|
|
87
87
|
const name = message.$sender.name?.trim();
|
|
88
88
|
return name || message.$sender.id;
|
|
89
89
|
}
|
|
@@ -93,5 +93,6 @@ export function boardMessageMatches(stored: string, messageId: string): boolean
|
|
|
93
93
|
if (!stored || !messageId) return false;
|
|
94
94
|
if (stored === messageId || stored.endsWith(`:${messageId}`)) return true;
|
|
95
95
|
const tail = stored.split(':').pop();
|
|
96
|
-
|
|
96
|
+
// 尾段匹配要求全等或以 `:tail` 结尾(段边界),避免 x99912345 误中 12345
|
|
97
|
+
return !!tail && (messageId === tail || messageId.endsWith(`:${tail}`));
|
|
97
98
|
}
|
package/src/grid-keyboard.ts
CHANGED
|
@@ -60,7 +60,8 @@ export function buildGridFallbackMap(
|
|
|
60
60
|
const map: Record<string, string> = {};
|
|
61
61
|
let n = 1;
|
|
62
62
|
for (let i = 0; i < cells.length; i++) {
|
|
63
|
-
|
|
63
|
+
const cell = cells[i];
|
|
64
|
+
if (cell && !cell.disabled) {
|
|
64
65
|
map[String(n)] = `${gamePrefix}:${sessionId}:${i}`;
|
|
65
66
|
n++;
|
|
66
67
|
}
|
|
@@ -95,7 +96,10 @@ export function buildGridKeyboard<T>(options: GridKeyboardOptions<T>): SendConte
|
|
|
95
96
|
const row: ReturnType<typeof segment.button>[] = [];
|
|
96
97
|
for (let c = 0; c < cols; c++) {
|
|
97
98
|
const i = r * cols + c;
|
|
98
|
-
const cell = cells[i]
|
|
99
|
+
const cell = cells[i];
|
|
100
|
+
if (!cell) {
|
|
101
|
+
throw new RangeError(`Missing grid cell at index ${i}`);
|
|
102
|
+
}
|
|
99
103
|
row.push(
|
|
100
104
|
segment.button({
|
|
101
105
|
id: `c${i}`,
|
|
@@ -134,7 +138,8 @@ export function buildGridKeyboard<T>(options: GridKeyboardOptions<T>): SendConte
|
|
|
134
138
|
);
|
|
135
139
|
}),
|
|
136
140
|
);
|
|
137
|
-
|
|
141
|
+
// postChoices 编号从可落子格子数后续起,避免覆盖格子数字映射
|
|
142
|
+
Object.assign(fallback, buildChoiceFallbackMap(gamePrefix, sessionId, postChoices, Object.keys(fallback).length + 1));
|
|
138
143
|
}
|
|
139
144
|
|
|
140
145
|
const parts = [
|
|
@@ -161,11 +166,12 @@ export function parseGridPayload(
|
|
|
161
166
|
): { prefix: string; sessionId: string; cell: number } | null {
|
|
162
167
|
const m = /^([a-z0-9_]+):([^:]+):(\d+)$/i.exec(payload);
|
|
163
168
|
if (!m) return null;
|
|
164
|
-
const prefix = m
|
|
169
|
+
const [, prefix, sessionId, rawCell] = m;
|
|
170
|
+
if (!prefix || !sessionId || rawCell === undefined) return null;
|
|
165
171
|
if (expectedPrefix && prefix !== expectedPrefix) return null;
|
|
166
|
-
const cell = Number(
|
|
172
|
+
const cell = Number(rawCell);
|
|
167
173
|
if (!Number.isInteger(cell) || cell < 0) return null;
|
|
168
|
-
return { prefix, sessionId
|
|
174
|
+
return { prefix, sessionId, cell };
|
|
169
175
|
}
|
|
170
176
|
|
|
171
177
|
/**
|
|
@@ -174,7 +180,9 @@ export function parseGridPayload(
|
|
|
174
180
|
export function parseCellButtonId(value: string): number | null {
|
|
175
181
|
const m = /^c(\d+)$/.exec(value);
|
|
176
182
|
if (!m) return null;
|
|
177
|
-
const
|
|
183
|
+
const rawCell = m[1];
|
|
184
|
+
if (rawCell === undefined) return null;
|
|
185
|
+
const cell = Number(rawCell);
|
|
178
186
|
if (!Number.isInteger(cell) || cell < 0) return null;
|
|
179
187
|
return cell;
|
|
180
188
|
}
|
package/src/memory-db.ts
CHANGED
|
@@ -24,8 +24,13 @@ function createMemoryModel(): InMemoryGameModel {
|
|
|
24
24
|
Object.entries(q).every(([k, v]) => row[k] === v);
|
|
25
25
|
|
|
26
26
|
return {
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// 返回行拷贝,避免调用方直接改字段绕过 updateWhere
|
|
28
|
+
findAll: async (q: Record<string, unknown> = {}) =>
|
|
29
|
+
rows.filter((row) => match(row, q)).map((row) => ({ ...row })),
|
|
30
|
+
findOne: async (q: Record<string, unknown> = {}) => {
|
|
31
|
+
const found = rows.find((row) => match(row, q));
|
|
32
|
+
return found ? { ...found } : null;
|
|
33
|
+
},
|
|
29
34
|
create: async (row: Row) => {
|
|
30
35
|
rows.push({ ...row });
|
|
31
36
|
return row;
|
|
@@ -43,7 +48,8 @@ function createMemoryModel(): InMemoryGameModel {
|
|
|
43
48
|
deleteWhere: async (where: Record<string, unknown>) => {
|
|
44
49
|
let n = 0;
|
|
45
50
|
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
46
|
-
|
|
51
|
+
const row = rows[i];
|
|
52
|
+
if (row && match(row, where)) {
|
|
47
53
|
rows.splice(i, 1);
|
|
48
54
|
n += 1;
|
|
49
55
|
}
|
|
@@ -69,7 +75,8 @@ export function createInMemoryGameDb(tableNames: readonly string[]): InMemoryGam
|
|
|
69
75
|
/** Minimal DatabaseHost model surface (structural typing; no plugin-runtime dep). */
|
|
70
76
|
export interface HostGameModelSource {
|
|
71
77
|
select(): {
|
|
72
|
-
|
|
78
|
+
// DatabaseHostSelection is a thenable, not an instance of Promise; accept both.
|
|
79
|
+
where(query: Record<string, unknown>): PromiseLike<Record<string, unknown>[]>;
|
|
73
80
|
};
|
|
74
81
|
insert(row: Record<string, unknown>): Promise<unknown>;
|
|
75
82
|
delete(): { where(query: Record<string, unknown>): Promise<unknown> };
|
package/src/random.ts
CHANGED
|
@@ -16,17 +16,25 @@ export function secureRandomIntInclusive(min: number, max: number): number {
|
|
|
16
16
|
|
|
17
17
|
export function secureRandomItem<T>(items: readonly T[]): T {
|
|
18
18
|
if (items.length === 0) throw new RangeError('cannot choose from an empty array');
|
|
19
|
-
return items
|
|
19
|
+
return itemAt(items, secureRandomInt(items.length));
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function secureShuffleInPlace<T>(items: T[]): T[] {
|
|
23
23
|
for (let i = items.length - 1; i > 0; i--) {
|
|
24
24
|
const j = secureRandomInt(i + 1);
|
|
25
|
-
|
|
25
|
+
const current = itemAt(items, i);
|
|
26
|
+
const selected = itemAt(items, j);
|
|
27
|
+
items[i] = selected;
|
|
28
|
+
items[j] = current;
|
|
26
29
|
}
|
|
27
30
|
return items;
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
function itemAt<T>(items: readonly T[], index: number): T {
|
|
34
|
+
if (!(index in items)) throw new RangeError(`missing array item at index ${index}`);
|
|
35
|
+
return items[index] as T;
|
|
36
|
+
}
|
|
37
|
+
|
|
30
38
|
export function generateCompactId(prefix: string): string {
|
|
31
39
|
return `${prefix}${Date.now().toString(36)}${randomUUID().replaceAll('-', '').slice(0, 8)}`;
|
|
32
40
|
}
|
package/src/runtime-hub.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Plugin Runtime game hub — module-level registry (no legacy Feature / getPlugin).
|
|
3
3
|
* Games call `registerRuntimeGame` from `setup()`; hub plugin lists them.
|
|
4
4
|
*/
|
|
5
|
+
import { bindGameRecordDatabase } from './game-records.js';
|
|
6
|
+
|
|
5
7
|
export interface RuntimeGameMenuAction {
|
|
6
8
|
readonly id: string;
|
|
7
9
|
readonly label: string;
|
|
@@ -25,7 +27,10 @@ export function registerRuntimeGame(game: RuntimeRegisteredGame): () => void {
|
|
|
25
27
|
const registrations = games.get(game.id) ?? [];
|
|
26
28
|
registrations.push(registered);
|
|
27
29
|
games.set(game.id, registrations);
|
|
30
|
+
// 把游戏绑定到其插件刚注册的战绩库(按 Host/插件作用域取库)
|
|
31
|
+
const unbindRecords = bindGameRecordDatabase(game.id);
|
|
28
32
|
return () => {
|
|
33
|
+
unbindRecords();
|
|
29
34
|
const current = games.get(game.id);
|
|
30
35
|
if (!current) return;
|
|
31
36
|
const index = current.lastIndexOf(registered);
|