@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 @@
1
+ {"version":3,"file":"versioned-session-service.js","sourceRoot":"","sources":["../src/versioned-session-service.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/game-kit",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Zhin.js 游戏插件通用工具包",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -13,8 +13,8 @@
13
13
  }
14
14
  },
15
15
  "peerDependencies": {
16
- "zhin.js": "^5.0.0",
17
- "@zhin.js/html-renderer": "2.0.0"
16
+ "zhin.js": "^5.0.2",
17
+ "@zhin.js/html-renderer": "2.0.2"
18
18
  },
19
19
  "peerDependenciesMeta": {
20
20
  "@zhin.js/html-renderer": {
@@ -22,13 +22,14 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
25
- "@zhin.js/core": "1.4.0",
26
- "@zhin.js/middleware": "1.0.2",
27
- "@zhin.js/satori": "1.0.16"
25
+ "@zhin.js/core": "1.4.2",
26
+ "@zhin.js/middleware": "1.0.4",
27
+ "@zhin.js/plugin-runtime": "1.1.1",
28
+ "@zhin.js/satori": "1.0.17"
28
29
  },
29
30
  "devDependencies": {
30
31
  "typescript": "^6.0.3",
31
- "zhin.js": "5.0.0"
32
+ "zhin.js": "5.0.2"
32
33
  },
33
34
  "files": [
34
35
  "lib",
@@ -0,0 +1,210 @@
1
+ import {
2
+ gameEvents,
3
+ type GameEventSession,
4
+ type GameOutcome,
5
+ } from './game-events.js';
6
+ import { gameSessionCoordinator, type GameSessionProvider } from './session-coordinator.js';
7
+
8
+ export interface BaseGameSessionRow extends GameEventSession {
9
+ id: string;
10
+ channel_key: string;
11
+ status: string;
12
+ updated_at: number;
13
+ created_at: number;
14
+ turn?: unknown;
15
+ }
16
+
17
+ export interface GameSessionModel<TRow extends BaseGameSessionRow> {
18
+ findAll(query?: Record<string, unknown>): Promise<TRow[]>;
19
+ findOne(query?: Record<string, unknown>): Promise<TRow | null>;
20
+ create(row: TRow): Promise<unknown>;
21
+ updateWhere(
22
+ where: Record<string, unknown>,
23
+ patch: Record<string, unknown>,
24
+ ): Promise<unknown>;
25
+ }
26
+
27
+ export interface GameSessionDatabase<TRow extends BaseGameSessionRow> {
28
+ readonly models: {
29
+ get(name: string): GameSessionModel<TRow> | undefined;
30
+ };
31
+ }
32
+
33
+ export interface BaseSessionServiceOptions<TRow extends BaseGameSessionRow> {
34
+ readonly gameId: string;
35
+ readonly table: string;
36
+ readonly userFields: readonly (keyof TRow & string)[];
37
+ readonly activeStatus?: string;
38
+ readonly abortedStatus?: string;
39
+ readonly projectOutcomes?: (
40
+ session: Readonly<TRow>,
41
+ previous: Readonly<TRow>,
42
+ ) => readonly GameOutcome[];
43
+ }
44
+
45
+ export type SessionTimeoutListener<TRow extends BaseGameSessionRow> = (
46
+ session: TRow,
47
+ ) => void | Promise<void>;
48
+
49
+ /**
50
+ * Shared session lifecycle for every game. Subclasses own row construction and
51
+ * game-specific tables; this module owns persistence invariants and events.
52
+ */
53
+ export abstract class BaseSessionService<TRow extends BaseGameSessionRow>
54
+ implements GameSessionProvider {
55
+ readonly gameId: string;
56
+ readonly #activeStatus: string;
57
+ readonly #abortedStatus: string;
58
+ readonly #userFields: readonly (keyof TRow & string)[];
59
+ readonly #projectOutcomes?: BaseSessionServiceOptions<TRow>['projectOutcomes'];
60
+ readonly #model: GameSessionModel<TRow>;
61
+
62
+ protected constructor(
63
+ database: GameSessionDatabase<TRow>,
64
+ options: BaseSessionServiceOptions<TRow>,
65
+ ) {
66
+ const model = database.models.get(options.table);
67
+ if (!model) throw new Error(`${options.table} not registered`);
68
+ this.#model = model;
69
+ this.gameId = options.gameId;
70
+ this.#userFields = options.userFields;
71
+ this.#projectOutcomes = options.projectOutcomes;
72
+ this.#activeStatus = options.activeStatus ?? 'active';
73
+ this.#abortedStatus = options.abortedStatus ?? 'aborted';
74
+ }
75
+
76
+ async getById(id: string): Promise<TRow | null> {
77
+ return this.#model.findOne({ id });
78
+ }
79
+
80
+ async getActiveByChannel(channelKey: string): Promise<TRow | null> {
81
+ const rows = await this.#model.findAll({
82
+ channel_key: channelKey,
83
+ status: this.#activeStatus,
84
+ });
85
+ return rows[0] ?? null;
86
+ }
87
+
88
+ async getActiveForUser(channelKey: string, userId: string): Promise<TRow | null> {
89
+ const rows = await this.#model.findAll({
90
+ channel_key: channelKey,
91
+ status: this.#activeStatus,
92
+ });
93
+ return rows.find((row) =>
94
+ this.#userFields.some((field) => row[field] === userId)) ?? null;
95
+ }
96
+
97
+ async getLatestForUser(channelKey: string, userId: string): Promise<TRow | null> {
98
+ const rows = await this.#model.findAll({ channel_key: channelKey });
99
+ const matches = rows.filter((row) =>
100
+ this.#userFields.some((field) => row[field] === userId));
101
+ return matches.reduce<TRow | null>(
102
+ (latest, row) => !latest || row.updated_at >= latest.updated_at ? row : latest,
103
+ null,
104
+ );
105
+ }
106
+
107
+ async updateSession(id: string, patch: Partial<TRow>): Promise<void> {
108
+ const previous = await this.getById(id);
109
+ if (!previous) return;
110
+ await this.commitSession(previous, patch, { id });
111
+ }
112
+
113
+ /**
114
+ * Persist one transition and emit lifecycle events from the row that was
115
+ * actually stored. Versioned services use `where` for optimistic locking.
116
+ */
117
+ protected async commitSession(
118
+ previous: TRow,
119
+ patch: Partial<TRow>,
120
+ where: Record<string, unknown>,
121
+ ): Promise<TRow | null> {
122
+ const updatedAt = Date.now();
123
+ const changed = await this.#model.updateWhere(
124
+ where,
125
+ { ...patch, updated_at: updatedAt },
126
+ );
127
+ if (typeof changed === 'number' && changed === 0) return null;
128
+ const session = await this.getById(previous.id);
129
+ if (!session) return null;
130
+ if (patch.turn !== undefined && patch.turn !== previous.turn) {
131
+ await gameEvents.emit('turn:change', {
132
+ gameId: this.gameId,
133
+ session,
134
+ previousTurn: previous.turn,
135
+ turn: patch.turn,
136
+ });
137
+ }
138
+ if (patch.status !== undefined
139
+ && previous.status === this.#activeStatus
140
+ && patch.status !== this.#activeStatus) {
141
+ await gameEvents.emit('game:end', {
142
+ gameId: this.gameId,
143
+ session,
144
+ previousStatus: previous.status,
145
+ outcomes: this.#projectOutcomes?.(session, previous) ?? [],
146
+ });
147
+ }
148
+ return session;
149
+ }
150
+
151
+ async abortStale(
152
+ idleMs: number,
153
+ onTimeout?: SessionTimeoutListener<TRow>,
154
+ ): Promise<number> {
155
+ const cutoff = Date.now() - idleMs;
156
+ const rows = await this.#model.findAll({ status: this.#activeStatus });
157
+ let count = 0;
158
+ for (const row of rows) {
159
+ if (row.updated_at >= cutoff) continue;
160
+ // The timestamp condition prevents a cleanup scan from aborting a
161
+ // session that received a player action after this row was selected.
162
+ const session = await this.commitSession(
163
+ row,
164
+ { status: this.#abortedStatus } as Partial<TRow>,
165
+ {
166
+ id: row.id,
167
+ status: this.#activeStatus,
168
+ updated_at: row.updated_at,
169
+ },
170
+ );
171
+ if (!session) continue;
172
+ await gameEvents.emit('session:timeout', {
173
+ gameId: this.gameId,
174
+ session,
175
+ idleMs,
176
+ });
177
+ // A broken outbound adapter must not prevent later stale sessions from
178
+ // being finalized. Persistence and lifecycle events already succeeded.
179
+ if (onTimeout) {
180
+ try {
181
+ await onTimeout(session);
182
+ } catch {
183
+ // Notification is best-effort; the next cleanup pass stays healthy.
184
+ }
185
+ }
186
+ count += 1;
187
+ }
188
+ return count;
189
+ }
190
+
191
+ registerCoordinator(): () => void {
192
+ return gameSessionCoordinator.register(this);
193
+ }
194
+
195
+ protected async createRow(row: TRow): Promise<TRow> {
196
+ const userIds: string[] = [];
197
+ for (const field of this.#userFields) {
198
+ const value: unknown = row[field];
199
+ if (typeof value === 'string') userIds.push(value);
200
+ }
201
+ await gameSessionCoordinator.assertAvailable(this.gameId, row.channel_key, userIds);
202
+ await this.#model.create(row);
203
+ await gameEvents.emit('game:start', { gameId: this.gameId, session: row });
204
+ return row;
205
+ }
206
+
207
+ protected get model(): GameSessionModel<TRow> {
208
+ return this.#model;
209
+ }
210
+ }
@@ -1,16 +1,15 @@
1
1
  /**
2
- * 游戏棋盘消息辅助工具
3
- * 提供会话键生成和消息 ID 更新回调封装
2
+ * 游戏棋盘会话键工具。
4
3
  *
5
- * 注意:实际的发送/编辑逻辑由 Adapter.editMessage 统一处理
6
- * - 支持编辑的平台:调用平台 API 编辑
7
- * - 不支持编辑的平台:自动 fallback 到发送新消息
4
+ * Plugin Runtime 下游戏逻辑操作的是 game-kit 自有的 `GameMessageLike`
5
+ * (由 `messageFromCommandInput` CommandContext.input 桥接而来),
6
+ * 不再依赖 `@zhin.js/core` Message 类型或 Adapter.editMessage 交互路径。
8
7
  */
9
- import type { Message } from '@zhin.js/core';
8
+ import type { GameMessageLike } from './command-message.js';
10
9
 
11
10
  /**
12
11
  * 构建频道唯一键(用于会话查找)
13
12
  */
14
- export function channelKey(message: Message): string {
13
+ export function channelKey(message: GameMessageLike): string {
15
14
  return `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
16
15
  }
@@ -35,9 +35,11 @@ export function buildChoiceFallbackMap(
35
35
  gamePrefix: string,
36
36
  sessionId: string,
37
37
  choices: ChoiceOption[],
38
+ /** 起始编号(默认 1);拼在网格 fallback 之后时传空格数 +1,避免覆盖格子映射 */
39
+ startIndex = 1,
38
40
  ): Record<string, string> {
39
41
  const map: Record<string, string> = {};
40
- let n = 1;
42
+ let n = startIndex;
41
43
  for (const choice of choices) {
42
44
  if (!choice.disabled) {
43
45
  map[String(n)] = `${gamePrefix}:${sessionId}:${choice.id}`;
@@ -1,6 +1,8 @@
1
1
  import { defineMiddleware, type MiddlewareContext } from '@zhin.js/middleware';
2
2
  import type { Message } from '@zhin.js/core/runtime';
3
3
 
4
+ type AliasReply = Parameters<Message['$reply']>[0];
5
+
4
6
  /**
5
7
  * 游戏 legacy 命令别名路由。
6
8
  *
@@ -20,7 +22,7 @@ export interface GameCommandAliasRoute {
20
22
  action: string,
21
23
  input: unknown,
22
24
  context: MiddlewareContext<Message>,
23
- ): Promise<string | null | undefined>;
25
+ ): Promise<AliasReply | null | undefined>;
24
26
  }
25
27
 
26
28
  export function defineGameCommandAliasMiddleware(route: GameCommandAliasRoute) {
@@ -4,12 +4,20 @@
4
4
  * Runtime Message: { adapter, target, sender?, metadata? }
5
5
  * Classic Message: { $adapter, $endpoint, $channel, $sender }
6
6
  */
7
+ import type { SendContent } from '@zhin.js/core';
8
+
9
+ /** A game returns semantic content; Runtime owns native rendering and text fallback. */
10
+ export type GameReply = SendContent;
7
11
 
8
12
  export interface GameMessageLike {
9
13
  $adapter: string;
10
14
  $endpoint: string;
11
15
  $channel: { type: string; id: string };
12
- $sender: { id: string; name: string };
16
+ // `name` is optional so this stays a structural supertype of the core
17
+ // Message shape (whose $sender.name is string | undefined). That lets the
18
+ // shared game-kit helpers accept both the runtime-bridged GameMessageLike
19
+ // and, during incremental migration, a legacy core Message.
20
+ $sender: { id: string; name?: string };
13
21
  }
14
22
 
15
23
  function isClassicMessage(input: unknown): input is GameMessageLike {
@@ -0,0 +1,162 @@
1
+ import {
2
+ databaseHostToken,
3
+ definePlugin,
4
+ outboundHostToken,
5
+ scheduleHostToken,
6
+ type DatabaseHost,
7
+ type PluginMetadata,
8
+ type PluginSetupContext,
9
+ type Token,
10
+ } from '@zhin.js/plugin-runtime';
11
+ import {
12
+ createHostGameDb,
13
+ createInMemoryGameDb,
14
+ type InMemoryGameDb,
15
+ } from './memory-db.js';
16
+ import {
17
+ initGameRecordHost,
18
+ recordGameOutcome,
19
+ type GameRecordDatabaseHost,
20
+ } from './game-records.js';
21
+ import { gameEvents } from './game-events.js';
22
+ import {
23
+ DEFAULT_GAME_STALE_CRON,
24
+ DEFAULT_GAME_STALE_IDLE_MS,
25
+ registerRuntimeGame,
26
+ type RuntimeRegisteredGame,
27
+ } from './runtime-hub.js';
28
+ import type {
29
+ BaseGameSessionRow,
30
+ BaseSessionService,
31
+ } from './base-session-service.js';
32
+
33
+ export interface GamePluginSession<
34
+ TRow extends BaseGameSessionRow = BaseGameSessionRow,
35
+ > {
36
+ abortStale(
37
+ idleMs: number,
38
+ onTimeout?: (session: TRow) => void | Promise<void>,
39
+ ): Promise<number>;
40
+ registerCoordinator?(): () => void;
41
+ }
42
+
43
+ export interface DefineGamePluginOptions<
44
+ TServices,
45
+ TRow extends BaseGameSessionRow = BaseGameSessionRow,
46
+ TConfig = unknown,
47
+ > {
48
+ readonly name: string;
49
+ readonly metadata?: PluginMetadata;
50
+ readonly game: RuntimeRegisteredGame;
51
+ readonly tables: readonly string[];
52
+ readonly servicesToken: Token<TServices>;
53
+ readonly defineHostTables: (host: DatabaseHost) => void;
54
+ readonly createServices: (database: InMemoryGameDb) => TServices;
55
+ readonly session: (
56
+ services: TServices,
57
+ ) => GamePluginSession<TRow> | BaseSessionService<TRow>;
58
+ readonly stale?: {
59
+ readonly cron?: string;
60
+ readonly idleMs?: number;
61
+ readonly description?: string;
62
+ readonly message?: string | ((session: TRow) => string);
63
+ readonly onTimeout?: (
64
+ session: TRow,
65
+ context: PluginSetupContext<TConfig>,
66
+ ) => void | Promise<void>;
67
+ } | false;
68
+ readonly setup?: (
69
+ context: PluginSetupContext<TConfig>,
70
+ services: TServices,
71
+ ) => void | (() => void) | Promise<void | (() => void)>;
72
+ }
73
+
74
+ /**
75
+ * Declarative Plugin Runtime assembly for games. Database selection, service
76
+ * publication, Hub registration, stale cleanup and HMR disposal live here.
77
+ */
78
+ export function defineGamePlugin<
79
+ TServices,
80
+ TRow extends BaseGameSessionRow = BaseGameSessionRow,
81
+ TConfig = unknown,
82
+ >(
83
+ options: DefineGamePluginOptions<TServices, TRow, TConfig>,
84
+ ) {
85
+ return definePlugin<TConfig>({
86
+ name: options.name,
87
+ metadata: options.metadata,
88
+ async setup(context) {
89
+ let database: InMemoryGameDb;
90
+ if (context.resources.has(databaseHostToken)) {
91
+ const host = context.resources.use(databaseHostToken);
92
+ options.defineHostTables(host);
93
+ database = createHostGameDb(host, options.tables);
94
+ context.lifecycle.add(
95
+ initGameRecordHost(host as unknown as GameRecordDatabaseHost),
96
+ );
97
+ } else {
98
+ database = createInMemoryGameDb(options.tables);
99
+ }
100
+
101
+ const services = options.createServices(database);
102
+ context.resources.provide(options.servicesToken, services);
103
+
104
+ const session = options.session(services);
105
+ const disposeCoordinator = session.registerCoordinator?.();
106
+ if (disposeCoordinator) context.lifecycle.add(disposeCoordinator);
107
+ context.lifecycle.add(registerRuntimeGame(options.game));
108
+ context.lifecycle.add(gameEvents.on('game:end', async (event) => {
109
+ if (event.gameId !== options.game.id || event.outcomes.length === 0) return;
110
+ const row = event.session;
111
+ if (!row.adapter || !row.endpoint || !row.channel_type || !row.channel_id) return;
112
+ for (const outcome of event.outcomes) {
113
+ await recordGameOutcome({
114
+ $adapter: row.adapter,
115
+ $endpoint: row.endpoint,
116
+ $channel: { type: row.channel_type, id: row.channel_id },
117
+ $sender: {
118
+ id: outcome.userId,
119
+ name: outcome.userName ?? outcome.userId,
120
+ },
121
+ }, event.gameId, outcome.result, outcome.score);
122
+ }
123
+ }));
124
+
125
+ if (options.stale !== false && context.resources.has(scheduleHostToken)) {
126
+ const stale = options.stale ?? {};
127
+ const schedule = context.resources.use(scheduleHostToken);
128
+ context.lifecycle.add(schedule.register({
129
+ id: `${options.game.id}/abort-stale`,
130
+ cron: stale.cron ?? DEFAULT_GAME_STALE_CRON,
131
+ description: stale.description
132
+ ?? `Abort stale ${options.name} sessions`,
133
+ async execute() {
134
+ await session.abortStale(
135
+ stale.idleMs ?? DEFAULT_GAME_STALE_IDLE_MS,
136
+ async (row) => {
137
+ await stale.onTimeout?.(row, context);
138
+ if (!context.resources.has(outboundHostToken)) return;
139
+ if (!row.adapter || !row.endpoint
140
+ || !row.channel_type || !row.channel_id) return;
141
+ const outbound = context.resources.use(outboundHostToken);
142
+ await outbound.send({
143
+ adapter: row.adapter,
144
+ endpointId: row.endpoint,
145
+ channelType: row.channel_type,
146
+ channelId: row.channel_id,
147
+ content: typeof stale.message === 'function'
148
+ ? stale.message(row)
149
+ : stale.message
150
+ ?? `${options.game.icon} ${options.game.title}对局因长时间无操作已结束。`,
151
+ });
152
+ },
153
+ );
154
+ },
155
+ }));
156
+ }
157
+
158
+ const dispose = await options.setup?.(context, services);
159
+ if (dispose) context.lifecycle.add(dispose);
160
+ },
161
+ });
162
+ }
@@ -0,0 +1,81 @@
1
+ const UINT32_RANGE = 0x1_0000_0000;
2
+ const ZERO_STATE_FALLBACK = 0x6d2b79f5;
3
+
4
+ /**
5
+ * Serializable xorshift32 generator for replayable game simulations.
6
+ * Security-sensitive randomness must continue using secureRandomInt.
7
+ */
8
+ export class DeterministicRandom {
9
+ #state: number;
10
+
11
+ private constructor(state: number) {
12
+ this.#state = normalizeState(state);
13
+ }
14
+
15
+ static fromSeed(seed: string | number): DeterministicRandom {
16
+ return new DeterministicRandom(
17
+ typeof seed === 'number' ? seed : hashSeed(seed),
18
+ );
19
+ }
20
+
21
+ static fromState(state: number): DeterministicRandom {
22
+ return new DeterministicRandom(state);
23
+ }
24
+
25
+ get state(): number {
26
+ return this.#state;
27
+ }
28
+
29
+ nextUint32(): number {
30
+ let value = this.#state;
31
+ value ^= value << 13;
32
+ value ^= value >>> 17;
33
+ value ^= value << 5;
34
+ this.#state = value >>> 0;
35
+ return this.#state;
36
+ }
37
+
38
+ int(maxExclusive: number): number {
39
+ if (!Number.isSafeInteger(maxExclusive)
40
+ || maxExclusive <= 0
41
+ || maxExclusive > UINT32_RANGE) {
42
+ throw new RangeError(
43
+ 'maxExclusive must be an integer between 1 and 2^32',
44
+ );
45
+ }
46
+ const limit = Math.floor(UINT32_RANGE / maxExclusive) * maxExclusive;
47
+ let value: number;
48
+ do {
49
+ value = this.nextUint32();
50
+ } while (value >= limit);
51
+ return value % maxExclusive;
52
+ }
53
+
54
+ intInclusive(min: number, max: number): number {
55
+ if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max) || max < min) {
56
+ throw new RangeError('invalid inclusive random range');
57
+ }
58
+ return min + this.int(max - min + 1);
59
+ }
60
+
61
+ item<T>(items: readonly T[]): T {
62
+ if (items.length === 0) {
63
+ throw new RangeError('cannot choose from an empty array');
64
+ }
65
+ return items[this.int(items.length)] as T;
66
+ }
67
+ }
68
+
69
+ function normalizeState(state: number): number {
70
+ if (!Number.isFinite(state)) throw new RangeError('state must be finite');
71
+ return (state >>> 0) || ZERO_STATE_FALLBACK;
72
+ }
73
+
74
+ function hashSeed(seed: string): number {
75
+ let hash = 0x811c9dc5;
76
+ for (let index = 0; index < seed.length; index += 1) {
77
+ hash ^= seed.charCodeAt(index);
78
+ hash = Math.imul(hash, 0x01000193);
79
+ }
80
+ return hash >>> 0;
81
+ }
@@ -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
  }