@zhin.js/game-kit 2.0.1 → 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/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 +3 -0
- 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-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 +0 -1
- package/lib/game-records.d.ts.map +1 -1
- package/lib/game-records.js +0 -60
- 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 +0 -29
- package/lib/game-session.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 +8 -0
- package/lib/memory-db.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 +7 -6
- package/src/base-session-service.ts +210 -0
- package/src/command-alias.ts +3 -1
- package/src/command-message.ts +4 -0
- package/src/define-game-plugin.ts +162 -0
- package/src/deterministic-random.ts +81 -0
- package/src/game-events.ts +75 -0
- package/src/game-interactive.ts +1 -80
- package/src/game-records.ts +0 -73
- package/src/game-session.ts +0 -83
- package/src/index.ts +6 -9
- package/src/memory-db.ts +12 -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 -13
- package/lib/game-hub-mount.d.ts.map +0 -1
- package/lib/game-hub-mount.js +0 -89
- 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 -67
- 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 -147
- package/src/game-middleware.ts +0 -14
- package/src/game-onboarding.ts +0 -71
- package/src/help-card.ts +0 -188
|
@@ -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
|
+
}
|
|
@@ -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,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
MessageCommand,
|
|
3
2
|
type Database,
|
|
4
3
|
type DatabaseFeature,
|
|
5
4
|
type Models,
|
|
@@ -8,7 +7,6 @@ import {
|
|
|
8
7
|
} from '@zhin.js/core';
|
|
9
8
|
import { channelKey } from './board-sender.js';
|
|
10
9
|
import type { GameMessageLike } from './command-message.js';
|
|
11
|
-
import { getRegisteredGame, getRegisteredGames } from './game-hub-feature.js';
|
|
12
10
|
import { createHostGameDb, type HostGameDbSource } from './memory-db.js';
|
|
13
11
|
import { generateCompactId } from './random.js';
|
|
14
12
|
|
|
@@ -234,77 +232,6 @@ export async function getGameLeaderboard(
|
|
|
234
232
|
.slice(0, limit);
|
|
235
233
|
}
|
|
236
234
|
|
|
237
|
-
function gameTitle(gameId: string): string {
|
|
238
|
-
const g = getRegisteredGame(gameId);
|
|
239
|
-
return g ? `${g.icon} ${g.title}` : gameId;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function formatUserStats(stats: UserGameStats[]): string {
|
|
243
|
-
if (!stats.length) return '暂无战绩记录,快去 `/游戏` 开一局吧!';
|
|
244
|
-
const lines = stats.map((s) => {
|
|
245
|
-
const title = gameTitle(s.gameId);
|
|
246
|
-
return `• ${title}:${s.wins} 胜 ${s.losses} 负${s.draws ? ` ${s.draws} 平` : ''}(${s.games} 局,得分 ${s.totalScore})`;
|
|
247
|
-
});
|
|
248
|
-
return ['📊 **你的战绩**', '', ...lines].join('\n');
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function formatLeaderboard(gameId: string, entries: LeaderboardEntry[]): string {
|
|
252
|
-
const title = gameTitle(gameId);
|
|
253
|
-
if (!entries.length) {
|
|
254
|
-
return `${title} 在本群暂无排行,发送 \`/游戏\` 开始第一局!`;
|
|
255
|
-
}
|
|
256
|
-
const lines = entries.map((e, i) =>
|
|
257
|
-
`${i + 1}. ${e.userName} — ${e.wins} 胜 / ${e.games} 局(得分 ${e.totalScore})`,
|
|
258
|
-
);
|
|
259
|
-
return [`🏆 **${title} 本群排行**`, '', ...lines].join('\n');
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
export function mountGameRecordCommands(root: Plugin): () => void {
|
|
263
|
-
const disposers: (() => void)[] = [];
|
|
264
|
-
|
|
265
|
-
disposers.push(
|
|
266
|
-
root.addCommand(
|
|
267
|
-
new MessageCommand('/战绩')
|
|
268
|
-
.desc('查看本人在本群的游戏战绩')
|
|
269
|
-
.action(async (message) => {
|
|
270
|
-
const stats = await getUserGameStats(message.$sender.id, channelKey(message));
|
|
271
|
-
return formatUserStats(stats);
|
|
272
|
-
}),
|
|
273
|
-
),
|
|
274
|
-
root.addCommand(
|
|
275
|
-
new MessageCommand('/排行')
|
|
276
|
-
.desc('查看本群某游戏排行榜')
|
|
277
|
-
.action(async (message, ...args) => {
|
|
278
|
-
const query = args.join(' ').trim();
|
|
279
|
-
const games = getRegisteredGames();
|
|
280
|
-
if (!games.length) return '暂无已注册游戏。';
|
|
281
|
-
const firstGame = games[0];
|
|
282
|
-
if (!firstGame) return '暂无已注册游戏。';
|
|
283
|
-
let gameId = firstGame.id;
|
|
284
|
-
if (query) {
|
|
285
|
-
const hit = games.find(
|
|
286
|
-
(g) => g.id === query
|
|
287
|
-
|| g.title.includes(query)
|
|
288
|
-
|| g.commandPrefix.includes(query)
|
|
289
|
-
|| g.aliases?.some((a) => a.includes(query)),
|
|
290
|
-
);
|
|
291
|
-
if (!hit) {
|
|
292
|
-
const names = games.map((g) => g.title).join('、');
|
|
293
|
-
return `未找到游戏「${query}」。可选:${names}`;
|
|
294
|
-
}
|
|
295
|
-
gameId = hit.id;
|
|
296
|
-
}
|
|
297
|
-
const board = await getGameLeaderboard(gameId, channelKey(message));
|
|
298
|
-
return formatLeaderboard(gameId, board);
|
|
299
|
-
}),
|
|
300
|
-
),
|
|
301
|
-
);
|
|
302
|
-
|
|
303
|
-
return () => {
|
|
304
|
-
for (const d of disposers) d();
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
|
|
308
235
|
/** 测试专用 */
|
|
309
236
|
export function resetGameRecordsForTests(): void {
|
|
310
237
|
legacyDb = null;
|
package/src/game-session.ts
CHANGED
|
@@ -1,93 +1,10 @@
|
|
|
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;
|
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
|
@@ -72,6 +72,18 @@ export function createInMemoryGameDb(tableNames: readonly string[]): InMemoryGam
|
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
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
|
+
|
|
75
87
|
/** Minimal DatabaseHost model surface (structural typing; no plugin-runtime dep). */
|
|
76
88
|
export interface HostGameModelSource {
|
|
77
89
|
select(): {
|
|
@@ -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();
|