@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.
- package/README.md +73 -16
- package/lib/base-session-service.d.ts +54 -0
- package/lib/base-session-service.d.ts.map +1 -0
- package/lib/base-session-service.js +139 -0
- package/lib/base-session-service.js.map +1 -0
- 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 +4 -2
- package/lib/choice-keyboard.js.map +1 -1
- package/lib/command-alias.d.ts +3 -1
- package/lib/command-alias.d.ts.map +1 -1
- package/lib/command-alias.js.map +1 -1
- package/lib/command-message.d.ts +4 -1
- package/lib/command-message.d.ts.map +1 -1
- package/lib/command-message.js +0 -6
- package/lib/command-message.js.map +1 -1
- package/lib/define-game-plugin.d.ts +32 -0
- package/lib/define-game-plugin.d.ts.map +1 -0
- package/lib/define-game-plugin.js +87 -0
- package/lib/define-game-plugin.js.map +1 -0
- package/lib/deterministic-random.d.ts +16 -0
- package/lib/deterministic-random.d.ts.map +1 -0
- package/lib/deterministic-random.js +68 -0
- package/lib/deterministic-random.js.map +1 -0
- 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-events.d.ts +53 -0
- package/lib/game-events.d.ts.map +1 -0
- package/lib/game-events.js +26 -0
- package/lib/game-events.js.map +1 -0
- package/lib/game-interactive.d.ts +0 -22
- package/lib/game-interactive.d.ts.map +1 -1
- package/lib/game-interactive.js +1 -57
- package/lib/game-interactive.js.map +1 -1
- package/lib/game-records.d.ts +8 -3
- package/lib/game-records.d.ts.map +1 -1
- package/lib/game-records.js +27 -62
- package/lib/game-records.js.map +1 -1
- package/lib/game-session.d.ts +0 -59
- package/lib/game-session.d.ts.map +1 -1
- package/lib/game-session.js +2 -30
- package/lib/game-session.js.map +1 -1
- package/lib/grid-keyboard.d.ts.map +1 -1
- package/lib/grid-keyboard.js +2 -1
- package/lib/grid-keyboard.js.map +1 -1
- package/lib/index.d.ts +6 -9
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +6 -9
- package/lib/index.js.map +1 -1
- package/lib/memory-db.d.ts +6 -0
- package/lib/memory-db.d.ts.map +1 -1
- package/lib/memory-db.js +14 -2
- package/lib/memory-db.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/lib/session-coordinator.d.ts +27 -0
- package/lib/session-coordinator.d.ts.map +1 -0
- package/lib/session-coordinator.js +57 -0
- package/lib/session-coordinator.js.map +1 -0
- package/lib/versioned-session-service.d.ts +48 -0
- package/lib/versioned-session-service.d.ts.map +1 -0
- package/lib/versioned-session-service.js +116 -0
- package/lib/versioned-session-service.js.map +1 -0
- package/package.json +8 -7
- package/src/base-session-service.ts +210 -0
- package/src/board-sender.ts +6 -7
- package/src/choice-keyboard.ts +3 -1
- package/src/command-alias.ts +3 -1
- package/src/command-message.ts +9 -1
- package/src/define-game-plugin.ts +162 -0
- package/src/deterministic-random.ts +81 -0
- package/src/game-action-alias.ts +33 -0
- package/src/game-events.ts +75 -0
- package/src/game-interactive.ts +1 -80
- package/src/game-records.ts +30 -77
- package/src/game-session.ts +2 -84
- package/src/grid-keyboard.ts +2 -1
- package/src/index.ts +6 -9
- package/src/memory-db.ts +19 -2
- package/src/runtime-hub.ts +5 -0
- package/src/session-coordinator.ts +74 -0
- package/src/versioned-session-service.ts +162 -0
- package/lib/command-help.d.ts +0 -19
- package/lib/command-help.d.ts.map +0 -1
- package/lib/command-help.js +0 -91
- package/lib/command-help.js.map +0 -1
- package/lib/game-commands.d.ts +0 -3
- package/lib/game-commands.d.ts.map +0 -1
- package/lib/game-commands.js +0 -5
- package/lib/game-commands.js.map +0 -1
- package/lib/game-hub-feature.d.ts +0 -58
- package/lib/game-hub-feature.d.ts.map +0 -1
- package/lib/game-hub-feature.js +0 -102
- package/lib/game-hub-feature.js.map +0 -1
- package/lib/game-hub-flow.d.ts +0 -9
- package/lib/game-hub-flow.d.ts.map +0 -1
- package/lib/game-hub-flow.js +0 -90
- package/lib/game-hub-flow.js.map +0 -1
- package/lib/game-hub-menu-context.d.ts +0 -25
- package/lib/game-hub-menu-context.d.ts.map +0 -1
- package/lib/game-hub-menu-context.js +0 -64
- package/lib/game-hub-menu-context.js.map +0 -1
- package/lib/game-hub-menu.d.ts +0 -37
- package/lib/game-hub-menu.d.ts.map +0 -1
- package/lib/game-hub-menu.js +0 -162
- package/lib/game-hub-menu.js.map +0 -1
- package/lib/game-hub-mount.d.ts +0 -9
- package/lib/game-hub-mount.d.ts.map +0 -1
- package/lib/game-hub-mount.js +0 -108
- package/lib/game-hub-mount.js.map +0 -1
- package/lib/game-middleware.d.ts +0 -6
- package/lib/game-middleware.d.ts.map +0 -1
- package/lib/game-middleware.js +0 -9
- package/lib/game-middleware.js.map +0 -1
- package/lib/game-onboarding.d.ts +0 -8
- package/lib/game-onboarding.d.ts.map +0 -1
- package/lib/game-onboarding.js +0 -66
- package/lib/game-onboarding.js.map +0 -1
- package/lib/help-card.d.ts +0 -12
- package/lib/help-card.d.ts.map +0 -1
- package/lib/help-card.js +0 -129
- package/lib/help-card.js.map +0 -1
- package/src/command-help.ts +0 -117
- package/src/game-commands.ts +0 -4
- package/src/game-hub-feature.ts +0 -154
- package/src/game-hub-flow.ts +0 -114
- package/src/game-hub-menu-context.ts +0 -94
- package/src/game-hub-menu.ts +0 -223
- package/src/game-hub-mount.ts +0 -172
- package/src/game-middleware.ts +0 -14
- package/src/game-onboarding.ts +0 -69
- 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();
|
package/src/game-interactive.ts
CHANGED
|
@@ -1,18 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
}
|
package/src/game-records.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
132
|
+
message: GameMessageLike,
|
|
111
133
|
gameId: string,
|
|
112
134
|
result: GameRecordResult,
|
|
113
135
|
score = 0,
|
|
114
136
|
): Promise<void> {
|
|
115
|
-
const db =
|
|
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 =
|
|
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
|
}
|
package/src/game-session.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
+
// 尾段匹配要求全等或以 `:tail` 结尾(段边界),避免 x99912345 误中 12345
|
|
14
|
+
return !!tail && (messageId === tail || messageId.endsWith(`:${tail}`));
|
|
97
15
|
}
|
package/src/grid-keyboard.ts
CHANGED
|
@@ -138,7 +138,8 @@ export function buildGridKeyboard<T>(options: GridKeyboardOptions<T>): SendConte
|
|
|
138
138
|
);
|
|
139
139
|
}),
|
|
140
140
|
);
|
|
141
|
-
|
|
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 './
|
|
12
|
-
export * from './
|
|
13
|
-
export * from './
|
|
14
|
-
export * from './game-
|
|
15
|
-
export * from './
|
|
16
|
-
export * from './game-
|
|
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
|
-
|
|
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;
|
|
@@ -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(): {
|
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);
|
|
@@ -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();
|