@zhin.js/game-kit 2.0.0 → 2.0.2

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 (139) hide show
  1. package/README.md +73 -16
  2. package/lib/base-session-service.d.ts +54 -0
  3. package/lib/base-session-service.d.ts.map +1 -0
  4. package/lib/base-session-service.js +139 -0
  5. package/lib/base-session-service.js.map +1 -0
  6. package/lib/board-sender.d.ts +6 -7
  7. package/lib/board-sender.d.ts.map +1 -1
  8. package/lib/board-sender.js.map +1 -1
  9. package/lib/choice-keyboard.d.ts +3 -1
  10. package/lib/choice-keyboard.d.ts.map +1 -1
  11. package/lib/choice-keyboard.js +4 -2
  12. package/lib/choice-keyboard.js.map +1 -1
  13. package/lib/command-alias.d.ts +3 -1
  14. package/lib/command-alias.d.ts.map +1 -1
  15. package/lib/command-alias.js.map +1 -1
  16. package/lib/command-message.d.ts +4 -1
  17. package/lib/command-message.d.ts.map +1 -1
  18. package/lib/command-message.js +0 -6
  19. package/lib/command-message.js.map +1 -1
  20. package/lib/define-game-plugin.d.ts +32 -0
  21. package/lib/define-game-plugin.d.ts.map +1 -0
  22. package/lib/define-game-plugin.js +87 -0
  23. package/lib/define-game-plugin.js.map +1 -0
  24. package/lib/deterministic-random.d.ts +16 -0
  25. package/lib/deterministic-random.d.ts.map +1 -0
  26. package/lib/deterministic-random.js +68 -0
  27. package/lib/deterministic-random.js.map +1 -0
  28. package/lib/game-action-alias.d.ts +7 -0
  29. package/lib/game-action-alias.d.ts.map +1 -1
  30. package/lib/game-action-alias.js +25 -0
  31. package/lib/game-action-alias.js.map +1 -1
  32. package/lib/game-events.d.ts +53 -0
  33. package/lib/game-events.d.ts.map +1 -0
  34. package/lib/game-events.js +26 -0
  35. package/lib/game-events.js.map +1 -0
  36. package/lib/game-interactive.d.ts +0 -22
  37. package/lib/game-interactive.d.ts.map +1 -1
  38. package/lib/game-interactive.js +1 -57
  39. package/lib/game-interactive.js.map +1 -1
  40. package/lib/game-records.d.ts +8 -3
  41. package/lib/game-records.d.ts.map +1 -1
  42. package/lib/game-records.js +27 -62
  43. package/lib/game-records.js.map +1 -1
  44. package/lib/game-session.d.ts +0 -59
  45. package/lib/game-session.d.ts.map +1 -1
  46. package/lib/game-session.js +2 -30
  47. package/lib/game-session.js.map +1 -1
  48. package/lib/grid-keyboard.d.ts.map +1 -1
  49. package/lib/grid-keyboard.js +2 -1
  50. package/lib/grid-keyboard.js.map +1 -1
  51. package/lib/index.d.ts +6 -9
  52. package/lib/index.d.ts.map +1 -1
  53. package/lib/index.js +6 -9
  54. package/lib/index.js.map +1 -1
  55. package/lib/memory-db.d.ts +6 -0
  56. package/lib/memory-db.d.ts.map +1 -1
  57. package/lib/memory-db.js +14 -2
  58. package/lib/memory-db.js.map +1 -1
  59. package/lib/runtime-hub.d.ts +0 -4
  60. package/lib/runtime-hub.d.ts.map +1 -1
  61. package/lib/runtime-hub.js +8 -0
  62. package/lib/runtime-hub.js.map +1 -1
  63. package/lib/session-coordinator.d.ts +27 -0
  64. package/lib/session-coordinator.d.ts.map +1 -0
  65. package/lib/session-coordinator.js +57 -0
  66. package/lib/session-coordinator.js.map +1 -0
  67. package/lib/versioned-session-service.d.ts +48 -0
  68. package/lib/versioned-session-service.d.ts.map +1 -0
  69. package/lib/versioned-session-service.js +116 -0
  70. package/lib/versioned-session-service.js.map +1 -0
  71. package/package.json +8 -7
  72. package/src/base-session-service.ts +210 -0
  73. package/src/board-sender.ts +6 -7
  74. package/src/choice-keyboard.ts +3 -1
  75. package/src/command-alias.ts +3 -1
  76. package/src/command-message.ts +9 -1
  77. package/src/define-game-plugin.ts +162 -0
  78. package/src/deterministic-random.ts +81 -0
  79. package/src/game-action-alias.ts +33 -0
  80. package/src/game-events.ts +75 -0
  81. package/src/game-interactive.ts +1 -80
  82. package/src/game-records.ts +30 -77
  83. package/src/game-session.ts +2 -84
  84. package/src/grid-keyboard.ts +2 -1
  85. package/src/index.ts +6 -9
  86. package/src/memory-db.ts +19 -2
  87. package/src/runtime-hub.ts +5 -0
  88. package/src/session-coordinator.ts +74 -0
  89. package/src/versioned-session-service.ts +162 -0
  90. package/lib/command-help.d.ts +0 -19
  91. package/lib/command-help.d.ts.map +0 -1
  92. package/lib/command-help.js +0 -91
  93. package/lib/command-help.js.map +0 -1
  94. package/lib/game-commands.d.ts +0 -3
  95. package/lib/game-commands.d.ts.map +0 -1
  96. package/lib/game-commands.js +0 -5
  97. package/lib/game-commands.js.map +0 -1
  98. package/lib/game-hub-feature.d.ts +0 -58
  99. package/lib/game-hub-feature.d.ts.map +0 -1
  100. package/lib/game-hub-feature.js +0 -102
  101. package/lib/game-hub-feature.js.map +0 -1
  102. package/lib/game-hub-flow.d.ts +0 -9
  103. package/lib/game-hub-flow.d.ts.map +0 -1
  104. package/lib/game-hub-flow.js +0 -90
  105. package/lib/game-hub-flow.js.map +0 -1
  106. package/lib/game-hub-menu-context.d.ts +0 -25
  107. package/lib/game-hub-menu-context.d.ts.map +0 -1
  108. package/lib/game-hub-menu-context.js +0 -64
  109. package/lib/game-hub-menu-context.js.map +0 -1
  110. package/lib/game-hub-menu.d.ts +0 -37
  111. package/lib/game-hub-menu.d.ts.map +0 -1
  112. package/lib/game-hub-menu.js +0 -162
  113. package/lib/game-hub-menu.js.map +0 -1
  114. package/lib/game-hub-mount.d.ts +0 -9
  115. package/lib/game-hub-mount.d.ts.map +0 -1
  116. package/lib/game-hub-mount.js +0 -108
  117. package/lib/game-hub-mount.js.map +0 -1
  118. package/lib/game-middleware.d.ts +0 -6
  119. package/lib/game-middleware.d.ts.map +0 -1
  120. package/lib/game-middleware.js +0 -9
  121. package/lib/game-middleware.js.map +0 -1
  122. package/lib/game-onboarding.d.ts +0 -8
  123. package/lib/game-onboarding.d.ts.map +0 -1
  124. package/lib/game-onboarding.js +0 -66
  125. package/lib/game-onboarding.js.map +0 -1
  126. package/lib/help-card.d.ts +0 -12
  127. package/lib/help-card.d.ts.map +0 -1
  128. package/lib/help-card.js +0 -129
  129. package/lib/help-card.js.map +0 -1
  130. package/src/command-help.ts +0 -117
  131. package/src/game-commands.ts +0 -4
  132. package/src/game-hub-feature.ts +0 -154
  133. package/src/game-hub-flow.ts +0 -114
  134. package/src/game-hub-menu-context.ts +0 -94
  135. package/src/game-hub-menu.ts +0 -223
  136. package/src/game-hub-mount.ts +0 -172
  137. package/src/game-middleware.ts +0 -14
  138. package/src/game-onboarding.ts +0 -69
  139. package/src/help-card.ts +0 -188
@@ -0,0 +1,75 @@
1
+ export interface GameEventSession {
2
+ readonly id: string;
3
+ readonly channel_key: string;
4
+ readonly status: string;
5
+ readonly updated_at: number;
6
+ readonly adapter?: string;
7
+ readonly endpoint?: string;
8
+ readonly channel_type?: string;
9
+ readonly channel_id?: string;
10
+ }
11
+
12
+ export interface GameOutcome {
13
+ readonly userId: string;
14
+ readonly userName?: string;
15
+ readonly result: 'won' | 'lost' | 'draw' | 'aborted';
16
+ readonly score?: number;
17
+ }
18
+
19
+ export interface GameEventMap {
20
+ 'game:start': {
21
+ readonly gameId: string;
22
+ readonly session: GameEventSession;
23
+ };
24
+ 'game:end': {
25
+ readonly gameId: string;
26
+ readonly session: GameEventSession;
27
+ readonly previousStatus: string;
28
+ readonly outcomes: readonly GameOutcome[];
29
+ };
30
+ 'turn:change': {
31
+ readonly gameId: string;
32
+ readonly session: GameEventSession;
33
+ readonly previousTurn: unknown;
34
+ readonly turn: unknown;
35
+ };
36
+ 'session:timeout': {
37
+ readonly gameId: string;
38
+ readonly session: GameEventSession;
39
+ readonly idleMs: number;
40
+ };
41
+ }
42
+
43
+ export type GameEventName = keyof GameEventMap;
44
+ export type GameEventListener<K extends GameEventName> = (
45
+ event: GameEventMap[K],
46
+ ) => void | Promise<void>;
47
+
48
+ /**
49
+ * Small async pub/sub used by game modules. Listener failures are isolated so a
50
+ * notification adapter cannot roll back an already committed session update.
51
+ */
52
+ export class GameEventBus {
53
+ readonly #listeners = new Map<GameEventName, Set<GameEventListener<GameEventName>>>();
54
+
55
+ on<K extends GameEventName>(name: K, listener: GameEventListener<K>): () => void {
56
+ const listeners = this.#listeners.get(name) ?? new Set();
57
+ listeners.add(listener as GameEventListener<GameEventName>);
58
+ this.#listeners.set(name, listeners);
59
+ return () => {
60
+ listeners.delete(listener as GameEventListener<GameEventName>);
61
+ if (listeners.size === 0) this.#listeners.delete(name);
62
+ };
63
+ }
64
+
65
+ async emit<K extends GameEventName>(name: K, event: GameEventMap[K]): Promise<void> {
66
+ const listeners = [...(this.#listeners.get(name) ?? [])];
67
+ await Promise.allSettled(listeners.map((listener) => listener(event)));
68
+ }
69
+
70
+ clear(): void {
71
+ this.#listeners.clear();
72
+ }
73
+ }
74
+
75
+ export const gameEvents = new GameEventBus();
@@ -1,18 +1,4 @@
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
- }
1
+ import { resolvePayloadFromText } from '@zhin.js/core';
16
2
 
17
3
  /** 从 QQ 指令预填 / 数字 fallback 解析游戏 payload */
18
4
  export function resolveGameTextPayload(
@@ -21,68 +7,3 @@ export function resolveGameTextPayload(
21
7
  ): string | undefined {
22
8
  return resolvePayloadFromText(raw, map);
23
9
  }
24
-
25
- export interface GameSessionRef {
26
- id: string;
27
- channel_key: string;
28
- }
29
-
30
- export interface ResolveGameChoiceOptions {
31
- message: Message;
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
- }
@@ -1,14 +1,12 @@
1
1
  import {
2
- MessageCommand,
3
2
  type Database,
4
3
  type DatabaseFeature,
5
- type Message,
6
4
  type Models,
7
5
  type Plugin,
8
6
  type RelatedModel,
9
7
  } from '@zhin.js/core';
10
8
  import { channelKey } from './board-sender.js';
11
- import { getRegisteredGame, getRegisteredGames } from './game-hub-feature.js';
9
+ import type { GameMessageLike } from './command-message.js';
12
10
  import { createHostGameDb, type HostGameDbSource } from './memory-db.js';
13
11
  import { generateCompactId } from './random.js';
14
12
 
@@ -39,10 +37,33 @@ const hostRegistrations: Array<{
39
37
  readonly database: GameRecordDb;
40
38
  }> = [];
41
39
 
40
+ /** gameId → 该游戏插件 setup 时注册的战绩库(按插件作用域取库,避免多 Host 时全局最后注册者胜) */
41
+ const gameDatabases = new Map<string, GameRecordDb>();
42
+ /** 最近一次 initGameRecordHost 注册的库;registerRuntimeGame 借此把游戏绑定到所属 Host 的库 */
43
+ let latestHostDatabase: GameRecordDb | null = null;
44
+
42
45
  function activeDatabase(): GameRecordDb | null {
43
46
  return hostRegistrations[hostRegistrations.length - 1]?.database ?? legacyDb;
44
47
  }
45
48
 
49
+ /** 按游戏作用域取库:优先该游戏插件注册时绑定的 Host 库,回退到全局活跃库 */
50
+ function databaseForGame(gameId: string): GameRecordDb | null {
51
+ return gameDatabases.get(gameId) ?? activeDatabase();
52
+ }
53
+
54
+ /**
55
+ * 由 registerRuntimeGame 在游戏注册时调用:把游戏绑定到当前插件刚通过 initGameRecordHost 注册的战绩库。
56
+ * 返回解绑函数(随游戏注销一并解绑)。
57
+ */
58
+ export function bindGameRecordDatabase(gameId: string): () => void {
59
+ const db = latestHostDatabase;
60
+ if (!db) return () => {};
61
+ gameDatabases.set(gameId, db);
62
+ return () => {
63
+ if (gameDatabases.get(gameId) === db) gameDatabases.delete(gameId);
64
+ };
65
+ }
66
+
46
67
  function getRecordModel(database: GameRecordDb): RelatedModel<unknown, Models, 'game_records'> {
47
68
  const model = database.models.get('game_records');
48
69
  if (!model) throw new Error('game_records not registered');
@@ -93,6 +114,7 @@ export function initGameRecordHost(host: GameRecordDatabaseHost): () => void {
93
114
  database = createHostGameDb(host, ['game_records']) as unknown as GameRecordDb;
94
115
  hostDatabases.set(host, database);
95
116
  }
117
+ latestHostDatabase = database;
96
118
  const registration = Object.freeze({ host, database });
97
119
  hostRegistrations.push(registration);
98
120
  return () => {
@@ -107,12 +129,12 @@ function recordId(): string {
107
129
 
108
130
  /** 对局结束时写入战绩(database 未就绪时静默跳过) */
109
131
  export async function recordGameOutcome(
110
- message: Message,
132
+ message: GameMessageLike,
111
133
  gameId: string,
112
134
  result: GameRecordResult,
113
135
  score = 0,
114
136
  ): Promise<void> {
115
- const db = activeDatabase();
137
+ const db = databaseForGame(gameId);
116
138
  if (!db) return;
117
139
  const row: GameRecordRow = {
118
140
  id: recordId(),
@@ -182,7 +204,7 @@ export async function getGameLeaderboard(
182
204
  channelKeyFilter: string,
183
205
  limit = 10,
184
206
  ): Promise<LeaderboardEntry[]> {
185
- const db = activeDatabase();
207
+ const db = databaseForGame(gameId);
186
208
  if (!db) return [];
187
209
  const rows = await getRecordModel(db).findAll({
188
210
  game_id: gameId,
@@ -210,80 +232,11 @@ export async function getGameLeaderboard(
210
232
  .slice(0, limit);
211
233
  }
212
234
 
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
- const firstGame = games[0];
258
- if (!firstGame) return '暂无已注册游戏。';
259
- let gameId = firstGame.id;
260
- if (query) {
261
- const hit = games.find(
262
- (g) => g.id === query
263
- || g.title.includes(query)
264
- || g.commandPrefix.includes(query)
265
- || g.aliases?.some((a) => a.includes(query)),
266
- );
267
- if (!hit) {
268
- const names = games.map((g) => g.title).join('、');
269
- return `未找到游戏「${query}」。可选:${names}`;
270
- }
271
- gameId = hit.id;
272
- }
273
- const board = await getGameLeaderboard(gameId, channelKey(message));
274
- return formatLeaderboard(gameId, board);
275
- }),
276
- ),
277
- );
278
-
279
- return () => {
280
- for (const d of disposers) d();
281
- };
282
- }
283
-
284
235
  /** 测试专用 */
285
236
  export function resetGameRecordsForTests(): void {
286
237
  legacyDb = null;
287
238
  hostDatabases = new WeakMap();
288
239
  hostRegistrations.splice(0);
240
+ gameDatabases.clear();
241
+ latestHostDatabase = null;
289
242
  }
@@ -1,97 +1,15 @@
1
- /**
2
- * 通用游戏会话接口
3
- * 定义回合制游戏的基础会话结构
4
- */
5
- import type { Message } from '@zhin.js/core';
6
1
  import { generateCompactId } from './random.js';
7
2
 
8
- /** 会话状态 */
9
- export type GameStatus = 'active' | 'won' | 'draw' | 'aborted';
10
-
11
- /** 通用游戏会话接口 */
12
- export interface GameSession {
13
- /** 会话 ID */
14
- id: string;
15
- /** 适配器名称 */
16
- adapter: string;
17
- /** Endpoint 名称 */
18
- endpoint: string;
19
- /** 频道类型 */
20
- channel_type: 'private' | 'group' | 'channel';
21
- /** 频道 ID */
22
- channel_id: string;
23
- /** 频道唯一键 */
24
- channel_key: string;
25
- /** 棋盘消息 ID(用于编辑) */
26
- board_message_id: string;
27
- /** 当前状态 */
28
- status: GameStatus;
29
- /** 更新时间戳 */
30
- updated_at: number;
31
- /** 创建时间戳 */
32
- created_at: number;
33
- }
34
-
35
- /** 回合制游戏会话(2 人对战) */
36
- export interface TurnBasedSession extends GameSession {
37
- /** 玩家 1 ID */
38
- player_1: string;
39
- /** 玩家 2 ID */
40
- player_2: string;
41
- /** 玩家 1 显示名 */
42
- player_1_name: string;
43
- /** 玩家 2 显示名 */
44
- player_2_name: string;
45
- /** 当前回合(1 或 2) */
46
- turn: 1 | 2;
47
- /** 胜者(0=无,1=玩家1,2=玩家2) */
48
- winner: 0 | 1 | 2;
49
- /** 步数 */
50
- move_count: number;
51
- }
52
-
53
3
  /** 生成会话 ID */
54
4
  export function generateSessionId(): string {
55
5
  return generateCompactId('s');
56
6
  }
57
7
 
58
- /** 获取当前回合玩家 ID */
59
- export function currentPlayerId(session: TurnBasedSession): string {
60
- return session.turn === 1 ? session.player_1 : session.player_2;
61
- }
62
-
63
- /** 判断用户是否为本局玩家 */
64
- export function isPlayer(session: TurnBasedSession, userId: string): boolean {
65
- return session.player_1 === userId || session.player_2 === userId;
66
- }
67
-
68
- /** 判断是否轮到该用户 */
69
- export function isPlayerTurn(session: TurnBasedSession, userId: string): boolean {
70
- return currentPlayerId(session) === userId;
71
- }
72
-
73
- /** 获取玩家编号(1 或 2),非玩家返回 null */
74
- export function playerNumber(session: TurnBasedSession, userId: string): 1 | 2 | null {
75
- if (session.player_1 === userId) return 1;
76
- if (session.player_2 === userId) return 2;
77
- return null;
78
- }
79
-
80
- /** 切换回合 */
81
- export function nextTurn(current: 1 | 2): 1 | 2 {
82
- return current === 1 ? 2 : 1;
83
- }
84
-
85
- /** 从 Message 提取发送者显示名 */
86
- export function senderDisplayName(message: Message): string {
87
- const name = message.$sender.name?.trim();
88
- return name || message.$sender.id;
89
- }
90
-
91
8
  /** 比对出站 board_message_id 与平台回调 messageId(含 composite id) */
92
9
  export function boardMessageMatches(stored: string, messageId: string): boolean {
93
10
  if (!stored || !messageId) return false;
94
11
  if (stored === messageId || stored.endsWith(`:${messageId}`)) return true;
95
12
  const tail = stored.split(':').pop();
96
- return !!(tail && messageId.endsWith(tail));
13
+ // 尾段匹配要求全等或以 `:tail` 结尾(段边界),避免 x99912345 误中 12345
14
+ return !!tail && (messageId === tail || messageId.endsWith(`:${tail}`));
97
15
  }
@@ -138,7 +138,8 @@ export function buildGridKeyboard<T>(options: GridKeyboardOptions<T>): SendConte
138
138
  );
139
139
  }),
140
140
  );
141
- Object.assign(fallback, buildChoiceFallbackMap(gamePrefix, sessionId, postChoices));
141
+ // postChoices 编号从可落子格子数后续起,避免覆盖格子数字映射
142
+ Object.assign(fallback, buildChoiceFallbackMap(gamePrefix, sessionId, postChoices, Object.keys(fallback).length + 1));
142
143
  }
143
144
 
144
145
  const parts = [
package/src/index.ts CHANGED
@@ -8,19 +8,16 @@ export * from './grid-keyboard.js';
8
8
  export * from './choice-keyboard.js';
9
9
  export * from './board-sender.js';
10
10
  export * from './game-session.js';
11
- export * from './game-hub-feature.js';
12
- export * from './game-hub-menu.js';
13
- export * from './game-hub-menu-context.js';
14
- export * from './game-hub-flow.js';
15
- export * from './game-hub-mount.js';
16
- export * from './game-middleware.js';
11
+ export * from './base-session-service.js';
12
+ export * from './versioned-session-service.js';
13
+ export * from './deterministic-random.js';
14
+ export * from './game-events.js';
15
+ export * from './session-coordinator.js';
16
+ export * from './define-game-plugin.js';
17
17
  export * from './game-action-alias.js';
18
- export * from './game-commands.js';
19
18
  export * from './game-interactive.js';
20
19
  export * from './interaction-profiles.js';
21
20
  export * from './game-records.js';
22
- export * from './game-onboarding.js';
23
- export * from './command-help.js';
24
21
  export * from './command-message.js';
25
22
  export * from './memory-db.js';
26
23
  export * from './send-content-text.js';
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;
@@ -67,6 +72,18 @@ export function createInMemoryGameDb(tableNames: readonly string[]): InMemoryGam
67
72
  };
68
73
  }
69
74
 
75
+ /**
76
+ * Typed boundary used by game tests and standalone embedders. The database
77
+ * implementation is deliberately structural so game packages keep their
78
+ * precise model types without each package recreating the same adapter.
79
+ */
80
+ export function createMemoryGameServices<TDatabase, TServices>(
81
+ tableNames: readonly string[],
82
+ factory: (database: TDatabase) => TServices,
83
+ ): TServices {
84
+ return factory(createInMemoryGameDb(tableNames) as unknown as TDatabase);
85
+ }
86
+
70
87
  /** Minimal DatabaseHost model surface (structural typing; no plugin-runtime dep). */
71
88
  export interface HostGameModelSource {
72
89
  select(): {
@@ -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);
@@ -0,0 +1,74 @@
1
+ export interface ActiveGameSession {
2
+ readonly id: string;
3
+ readonly channel_key: string;
4
+ }
5
+
6
+ export interface GameSessionProvider {
7
+ readonly gameId: string;
8
+ getActiveForUser(channelKey: string, userId: string): Promise<ActiveGameSession | null>;
9
+ }
10
+
11
+ export class GameSessionConflictError extends Error {
12
+ constructor(
13
+ readonly requestedGameId: string,
14
+ readonly activeGameId: string,
15
+ readonly sessionId: string,
16
+ readonly userId: string,
17
+ ) {
18
+ super(
19
+ `你在当前频道已有进行中的「${activeGameId}」对局,`
20
+ + `请先结束它,再开始「${requestedGameId}」。`,
21
+ );
22
+ this.name = 'GameSessionConflictError';
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Cross-game active-session index. Registrations form per-game stacks so HMR
28
+ * can install a new generation before disposing the old one without a gap.
29
+ */
30
+ export class GameSessionCoordinator {
31
+ readonly #providers = new Map<string, GameSessionProvider[]>();
32
+
33
+ register(provider: GameSessionProvider): () => void {
34
+ const providers = this.#providers.get(provider.gameId) ?? [];
35
+ providers.push(provider);
36
+ this.#providers.set(provider.gameId, providers);
37
+ return () => {
38
+ const current = this.#providers.get(provider.gameId);
39
+ if (!current) return;
40
+ const index = current.lastIndexOf(provider);
41
+ if (index >= 0) current.splice(index, 1);
42
+ if (current.length === 0) this.#providers.delete(provider.gameId);
43
+ };
44
+ }
45
+
46
+ async assertAvailable(
47
+ requestedGameId: string,
48
+ channelKey: string,
49
+ userIds: readonly string[],
50
+ ): Promise<void> {
51
+ for (const [gameId, registrations] of this.#providers) {
52
+ if (gameId === requestedGameId) continue;
53
+ const provider = registrations[registrations.length - 1];
54
+ if (!provider) continue;
55
+ for (const userId of new Set(userIds.filter(Boolean))) {
56
+ const active = await provider.getActiveForUser(channelKey, userId);
57
+ if (active) {
58
+ throw new GameSessionConflictError(
59
+ requestedGameId,
60
+ gameId,
61
+ active.id,
62
+ userId,
63
+ );
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ clear(): void {
70
+ this.#providers.clear();
71
+ }
72
+ }
73
+
74
+ export const gameSessionCoordinator = new GameSessionCoordinator();