@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.
Files changed (76) hide show
  1. package/lib/board-sender.d.ts +6 -7
  2. package/lib/board-sender.d.ts.map +1 -1
  3. package/lib/board-sender.js.map +1 -1
  4. package/lib/choice-keyboard.d.ts +3 -1
  5. package/lib/choice-keyboard.d.ts.map +1 -1
  6. package/lib/choice-keyboard.js +8 -4
  7. package/lib/choice-keyboard.js.map +1 -1
  8. package/lib/command-help.d.ts +1 -1
  9. package/lib/command-help.d.ts.map +1 -1
  10. package/lib/command-help.js.map +1 -1
  11. package/lib/command-message.d.ts +1 -1
  12. package/lib/command-message.d.ts.map +1 -1
  13. package/lib/command-message.js.map +1 -1
  14. package/lib/game-action-alias.d.ts +7 -0
  15. package/lib/game-action-alias.d.ts.map +1 -1
  16. package/lib/game-action-alias.js +25 -0
  17. package/lib/game-action-alias.js.map +1 -1
  18. package/lib/game-hub-feature.d.ts +3 -3
  19. package/lib/game-hub-feature.d.ts.map +1 -1
  20. package/lib/game-hub-feature.js +9 -7
  21. package/lib/game-hub-feature.js.map +1 -1
  22. package/lib/game-hub-flow.d.ts +2 -2
  23. package/lib/game-hub-flow.d.ts.map +1 -1
  24. package/lib/game-hub-flow.js.map +1 -1
  25. package/lib/game-hub-menu-context.d.ts +4 -4
  26. package/lib/game-hub-menu-context.d.ts.map +1 -1
  27. package/lib/game-hub-menu-context.js.map +1 -1
  28. package/lib/game-hub-mount.d.ts +4 -0
  29. package/lib/game-hub-mount.d.ts.map +1 -1
  30. package/lib/game-hub-mount.js +8 -27
  31. package/lib/game-hub-mount.js.map +1 -1
  32. package/lib/game-interactive.d.ts +1 -1
  33. package/lib/game-interactive.d.ts.map +1 -1
  34. package/lib/game-onboarding.d.ts.map +1 -1
  35. package/lib/game-onboarding.js +1 -0
  36. package/lib/game-onboarding.js.map +1 -1
  37. package/lib/game-records.d.ts +8 -2
  38. package/lib/game-records.d.ts.map +1 -1
  39. package/lib/game-records.js +31 -3
  40. package/lib/game-records.js.map +1 -1
  41. package/lib/game-session.d.ts +1 -1
  42. package/lib/game-session.d.ts.map +1 -1
  43. package/lib/game-session.js +2 -1
  44. package/lib/game-session.js.map +1 -1
  45. package/lib/grid-keyboard.d.ts.map +1 -1
  46. package/lib/grid-keyboard.js +16 -6
  47. package/lib/grid-keyboard.js.map +1 -1
  48. package/lib/memory-db.d.ts +1 -1
  49. package/lib/memory-db.d.ts.map +1 -1
  50. package/lib/memory-db.js +8 -3
  51. package/lib/memory-db.js.map +1 -1
  52. package/lib/random.d.ts.map +1 -1
  53. package/lib/random.js +10 -2
  54. package/lib/random.js.map +1 -1
  55. package/lib/runtime-hub.d.ts +0 -4
  56. package/lib/runtime-hub.d.ts.map +1 -1
  57. package/lib/runtime-hub.js +8 -0
  58. package/lib/runtime-hub.js.map +1 -1
  59. package/package.json +7 -7
  60. package/src/board-sender.ts +6 -7
  61. package/src/choice-keyboard.ts +6 -3
  62. package/src/command-help.ts +1 -1
  63. package/src/command-message.ts +5 -1
  64. package/src/game-action-alias.ts +33 -0
  65. package/src/game-hub-feature.ts +12 -9
  66. package/src/game-hub-flow.ts +2 -2
  67. package/src/game-hub-menu-context.ts +5 -5
  68. package/src/game-hub-mount.ts +10 -35
  69. package/src/game-interactive.ts +1 -1
  70. package/src/game-onboarding.ts +6 -2
  71. package/src/game-records.ts +33 -5
  72. package/src/game-session.ts +3 -2
  73. package/src/grid-keyboard.ts +15 -7
  74. package/src/memory-db.ts +11 -4
  75. package/src/random.ts +10 -2
  76. package/src/runtime-hub.ts +5 -0
@@ -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
  }
@@ -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: import('zhin.js').Message<any>;
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 = feature.register(game, plugin.name);
93
- plugin.recordFeatureContribution(feature.name, game.id);
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
- activeFeature = this;
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
- activeFeature = null;
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
- activeFeature = feature;
139
+ setActiveFeature(feature);
137
140
 
138
141
  if (root.started && feature.mounted) {
139
142
  feature.mounted(root);
@@ -42,7 +42,7 @@ function gameMenuChoices(gameId: string, channelType: string): HubMenuChoice[] {
42
42
  ];
43
43
  }
44
44
 
45
- export function openMainMenu(message: Message<any>): ReturnType<typeof buildMainHubMenu> | string {
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<any>,
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<any>;
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<any>): string {
30
+ function hubMenuKey(message: Message): string {
31
31
  return channelKey(message);
32
32
  }
33
33
 
34
- export function createHubScope(message: Message<any>): string {
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<any>,
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<any>): LastHubMenu | null {
59
+ export function getLastHubMenu(message: Message): LastHubMenu | null {
60
60
  pruneExpired();
61
61
  const key = hubMenuKey(message);
62
62
  const last = lastMenus.get(key);
@@ -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<any>): Promise<CommandHelpSource[]> {
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<any>) => {
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<any>): Promise<boolean> {
125
+ async function handleHubInteractive(root: Plugin, message: Message): Promise<boolean> {
151
126
  const action = getActionFromMessage(message);
152
127
  if (!action) return false;
153
128
 
@@ -28,7 +28,7 @@ export interface GameSessionRef {
28
28
  }
29
29
 
30
30
  export interface ResolveGameChoiceOptions {
31
- message: Message<any>;
31
+ message: Message;
32
32
  gamePrefix: string;
33
33
  validChoiceIds: readonly string[];
34
34
  getById: (sessionId: string) => Promise<GameSessionRef | null>;
@@ -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<any>): string {
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: Message<any>): boolean {
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
  }
@@ -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: Message<any>,
134
+ message: GameMessageLike,
111
135
  gameId: string,
112
136
  result: GameRecordResult,
113
137
  score = 0,
114
138
  ): Promise<void> {
115
- const db = activeDatabase();
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 = activeDatabase();
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
- let gameId = games[0]!.id;
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
  }
@@ -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<any>): string {
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
- return !!(tail && messageId.endsWith(tail));
96
+ // 尾段匹配要求全等或以 `:tail` 结尾(段边界),避免 x99912345 误中 12345
97
+ return !!tail && (messageId === tail || messageId.endsWith(`:${tail}`));
97
98
  }
@@ -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
- if (!cells[i]!.disabled) {
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
- Object.assign(fallback, buildChoiceFallbackMap(gamePrefix, sessionId, postChoices));
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[1]!;
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(m[3]);
172
+ const cell = Number(rawCell);
167
173
  if (!Number.isInteger(cell) || cell < 0) return null;
168
- return { prefix, sessionId: m[2]!, cell };
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 cell = Number(m[1]);
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
- findAll: async (q: Record<string, unknown> = {}) => rows.filter((row) => match(row, q)),
28
- findOne: async (q: Record<string, unknown> = {}) => rows.find((row) => match(row, q)) ?? null,
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
- if (match(rows[i]!, where)) {
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
- where(query: Record<string, unknown>): Promise<Record<string, unknown>[]>;
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[secureRandomInt(items.length)]!;
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
- [items[i], items[j]] = [items[j]!, items[i]!];
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
  }
@@ -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);