@zhin.js/plugin-rps 1.0.0

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 (47) hide show
  1. package/lib/commands.d.ts +6 -0
  2. package/lib/commands.d.ts.map +1 -0
  3. package/lib/commands.js +86 -0
  4. package/lib/commands.js.map +1 -0
  5. package/lib/engine.d.ts +9 -0
  6. package/lib/engine.d.ts.map +1 -0
  7. package/lib/engine.js +38 -0
  8. package/lib/engine.js.map +1 -0
  9. package/lib/game-flow.d.ts +14 -0
  10. package/lib/game-flow.d.ts.map +1 -0
  11. package/lib/game-flow.js +92 -0
  12. package/lib/game-flow.js.map +1 -0
  13. package/lib/hub-register.d.ts +5 -0
  14. package/lib/hub-register.d.ts.map +1 -0
  15. package/lib/hub-register.js +26 -0
  16. package/lib/hub-register.js.map +1 -0
  17. package/lib/index.d.ts +2 -0
  18. package/lib/index.d.ts.map +1 -0
  19. package/lib/index.js +26 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/models.d.ts +26 -0
  22. package/lib/models.d.ts.map +1 -0
  23. package/lib/models.js +20 -0
  24. package/lib/models.js.map +1 -0
  25. package/lib/rps-command.d.ts +5 -0
  26. package/lib/rps-command.d.ts.map +1 -0
  27. package/lib/rps-command.js +37 -0
  28. package/lib/rps-command.js.map +1 -0
  29. package/lib/session-service.d.ts +17 -0
  30. package/lib/session-service.d.ts.map +1 -0
  31. package/lib/session-service.js +87 -0
  32. package/lib/session-service.js.map +1 -0
  33. package/lib/view.d.ts +9 -0
  34. package/lib/view.d.ts.map +1 -0
  35. package/lib/view.js +49 -0
  36. package/lib/view.js.map +1 -0
  37. package/package.json +37 -0
  38. package/plugin.yml +2 -0
  39. package/src/commands.ts +102 -0
  40. package/src/engine.ts +44 -0
  41. package/src/game-flow.ts +127 -0
  42. package/src/hub-register.ts +30 -0
  43. package/src/index.ts +36 -0
  44. package/src/models.ts +47 -0
  45. package/src/rps-command.ts +45 -0
  46. package/src/session-service.ts +92 -0
  47. package/src/view.ts +56 -0
package/lib/view.js ADDED
@@ -0,0 +1,49 @@
1
+ import { buildChoiceKeyboard } from '@zhin.js/game-shared';
2
+ import { MOVE_LABELS, RPS_PREFIX, WIN_TARGET } from './engine.js';
3
+ export function buildRpsView(session, lastRound) {
4
+ const terminal = session.status !== 'active';
5
+ const lines = [
6
+ '✊✋✌️ **猜拳对决**',
7
+ '',
8
+ `比分 · 你 **${session.player_wins}** : **${session.bot_wins}** 机器人(先 ${WIN_TARGET} 胜)`,
9
+ ];
10
+ if (lastRound) {
11
+ lines.push('');
12
+ lines.push(`你出 ${MOVE_LABELS[lastRound.player]},机器人出 ${MOVE_LABELS[lastRound.bot]}`);
13
+ lines.push(lastRound.result === 0
14
+ ? '🤝 平局!'
15
+ : lastRound.result === 1
16
+ ? '🎉 你赢了这一局!'
17
+ : '😅 机器人赢了这一局。');
18
+ }
19
+ if (terminal) {
20
+ lines.push('');
21
+ if (session.status === 'won')
22
+ lines.push('🏆 **你赢得比赛!**');
23
+ else if (session.status === 'lost')
24
+ lines.push('💀 **机器人赢得比赛。**');
25
+ }
26
+ else if (!lastRound) {
27
+ lines.push('', '出拳吧!');
28
+ }
29
+ else {
30
+ lines.push('', '继续出拳:');
31
+ }
32
+ const choices = terminal
33
+ ? [{ id: 'restart', label: '🔄 再来一局', style: 'primary' }]
34
+ : ['rock', 'paper', 'scissors'].map((m) => ({
35
+ id: m,
36
+ label: MOVE_LABELS[m],
37
+ style: 'secondary',
38
+ }));
39
+ return buildChoiceKeyboard({
40
+ gamePrefix: RPS_PREFIX,
41
+ sessionId: session.id,
42
+ narrative: lines.join('\n'),
43
+ choices,
44
+ terminal,
45
+ buttonsPerRow: 3,
46
+ fallbackHint: '回复数字出拳(1石头 2布 3剪刀)',
47
+ });
48
+ }
49
+ //# sourceMappingURL=view.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"view.js","sourceRoot":"","sources":["../src/view.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAG3D,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAgB,MAAM,aAAa,CAAC;AAEhF,MAAM,UAAU,YAAY,CAC1B,OAAsB,EACtB,SAAgE;IAEhE,MAAM,QAAQ,GAAG,OAAO,CAAC,MAAM,KAAK,QAAQ,CAAC;IAC7C,MAAM,KAAK,GAAG;QACZ,eAAe;QACf,EAAE;QACF,YAAY,OAAO,CAAC,WAAW,UAAU,OAAO,CAAC,QAAQ,YAAY,UAAU,KAAK;KACrF,CAAC;IAEF,IAAI,SAAS,EAAE,CAAC;QACd,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACf,KAAK,CAAC,IAAI,CAAC,MAAM,WAAW,CAAC,SAAS,CAAC,MAAM,CAAC,SAAS,WAAW,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACrF,KAAK,CAAC,IAAI,CACR,SAAS,CAAC,MAAM,KAAK,CAAC;YACpB,CAAC,CAAC,QAAQ;YACV,CAAC,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC;gBACtB,CAAC,CAAC,YAAY;gBACd,CAAC,CAAC,cAAc,CACrB,CAAC;IACJ,CAAC;IAED,IAAI,QAAQ,EAAE,CAAC;QACb,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACf,IAAI,OAAO,CAAC,MAAM,KAAK,KAAK;YAAE,KAAK,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;aACrD,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM;YAAE,KAAK,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;IACpE,CAAC;SAAM,IAAI,CAAC,SAAS,EAAE,CAAC;QACtB,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;IACzB,CAAC;SAAM,CAAC;QACN,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;IAC1B,CAAC;IAED,MAAM,OAAO,GAAG,QAAQ;QACtB,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,SAAkB,EAAE,CAAC;QAClE,CAAC,CAAE,CAAC,MAAM,EAAE,OAAO,EAAE,UAAU,CAAe,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACvD,EAAE,EAAE,CAAC;YACL,KAAK,EAAE,WAAW,CAAC,CAAC,CAAC;YACrB,KAAK,EAAE,WAAoB;SAC5B,CAAC,CAAC,CAAC;IAER,OAAO,mBAAmB,CAAC;QACzB,UAAU,EAAE,UAAU;QACtB,SAAS,EAAE,OAAO,CAAC,EAAE;QACrB,SAAS,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC;QAC3B,OAAO;QACP,QAAQ;QACR,aAAa,EAAE,CAAC;QAChB,YAAY,EAAE,oBAAoB;KACnC,CAAC,CAAC;AACL,CAAC"}
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@zhin.js/plugin-rps",
3
+ "version": "1.0.0",
4
+ "description": "Zhin.js 猜拳游戏 — 石头剪刀布三局两胜",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "development": "./src/index.ts",
12
+ "import": "./lib/index.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "lib",
19
+ "plugin.yml",
20
+ "README.md"
21
+ ],
22
+ "license": "MIT",
23
+ "peerDependencies": {
24
+ "zhin.js": "4.1.0"
25
+ },
26
+ "dependencies": {
27
+ "@zhin.js/game-shared": "1.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "typescript": "^6.0.3",
31
+ "zhin.js": "4.1.0"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc --build",
35
+ "clean": "rimraf lib"
36
+ }
37
+ }
package/plugin.yml ADDED
@@ -0,0 +1,2 @@
1
+ name: "@zhin.js/plugin-rps"
2
+ description: "猜拳对决"
@@ -0,0 +1,102 @@
1
+ import { Message, MessageCommand, getActionFromMessage, type Plugin } from 'zhin.js';
2
+ import {
3
+ buildChoiceFallbackMap,
4
+ channelKey,
5
+ normalizeRpsAction,
6
+ parseChoicePayload,
7
+ registerGameTextMiddleware,
8
+ } from '@zhin.js/game-shared';
9
+ import { handleChoice, RPS_PREFIX } from './game-flow.js';
10
+ import { runRpsCommand } from './rps-command.js';
11
+ import type { SessionService } from './session-service.js';
12
+
13
+ function registerPattern(
14
+ plugin: Plugin,
15
+ pattern: string,
16
+ desc: string,
17
+ getServices: () => SessionService | null,
18
+ ): void {
19
+ plugin.addCommand(
20
+ new MessageCommand(pattern)
21
+ .desc(desc)
22
+ .action(async (message, result) => {
23
+ const services = getServices();
24
+ if (!services) return '猜拳需要启用 database 配置。';
25
+ const raw = (result.params.action as string | undefined) ?? '';
26
+ return runRpsCommand(plugin, services, message, normalizeRpsAction(raw));
27
+ }),
28
+ );
29
+ }
30
+
31
+ export function registerCommands(plugin: Plugin, getServices: () => SessionService | null): void {
32
+ registerPattern(plugin, 'rps [action:word]', '猜拳(rps)', getServices);
33
+ registerPattern(plugin, '猜拳 [action:word]', '猜拳(中文)', getServices);
34
+ }
35
+
36
+ async function resolveChoice(
37
+ message: Message<any>,
38
+ services: SessionService,
39
+ ): Promise<{ sessionId: string; choiceId: string } | null> {
40
+ const action = getActionFromMessage(message);
41
+ if (!action) return null;
42
+
43
+ const parsed = parseChoicePayload(action.payload, RPS_PREFIX);
44
+ if (parsed) return { sessionId: parsed.sessionId, choiceId: parsed.choiceId };
45
+
46
+ const choiceId = action.id || action.payload;
47
+ if (!choiceId) return null;
48
+
49
+ const ch = channelKey(message);
50
+ const session =
51
+ await services.getActiveForUser(ch, message.$sender.id)
52
+ ?? (action.sourceMessageId
53
+ ? await services.getActiveByBoardMessageId(action.sourceMessageId)
54
+ : null);
55
+ if (!session || session.channel_key !== ch) return null;
56
+
57
+ const valid = ['rock', 'paper', 'scissors', 'restart'];
58
+ if (!valid.includes(choiceId)) return null;
59
+ return { sessionId: session.id, choiceId };
60
+ }
61
+
62
+ export function registerInteractive(plugin: Plugin, getServices: () => SessionService | null): void {
63
+ plugin.registerInteractiveHandler(`${RPS_PREFIX}:`, async (message) => {
64
+ const services = getServices();
65
+ if (!services) return false;
66
+ const choice = await resolveChoice(message, services);
67
+ if (!choice) return false;
68
+ const err = await handleChoice(plugin, services, message, choice.sessionId, choice.choiceId);
69
+ if (err) await message.$reply?.(err);
70
+ return true;
71
+ });
72
+ }
73
+
74
+ export function registerTextFallback(plugin: Plugin, getServices: () => SessionService | null): void {
75
+ registerGameTextMiddleware(plugin, async (message, next) => {
76
+ const services = getServices();
77
+ if (!services) return next();
78
+
79
+ const action = getActionFromMessage(message);
80
+ if (action?.payload.startsWith(`${RPS_PREFIX}:`)) return next();
81
+
82
+ const ch = channelKey(message);
83
+ const session = await services.getActiveForUser(ch, message.$sender.id);
84
+ if (!session || session.status !== 'active') return next();
85
+
86
+ const raw = message.$raw?.trim() ?? '';
87
+ const n = /^(\d+)$/.exec(raw);
88
+ if (!n) return next();
89
+
90
+ const map = buildChoiceFallbackMap(RPS_PREFIX, session.id, [
91
+ { id: 'rock', label: '石头' },
92
+ { id: 'paper', label: '布' },
93
+ { id: 'scissors', label: '剪刀' },
94
+ ]);
95
+ const payload = map[n[1]!];
96
+ const parsed = payload ? parseChoicePayload(payload, RPS_PREFIX) : null;
97
+ if (!parsed || parsed.sessionId !== session.id) return next();
98
+
99
+ const err = await handleChoice(plugin, services, message, session.id, parsed.choiceId);
100
+ if (err) await message.$reply?.(err);
101
+ }, 'rps:text');
102
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,44 @@
1
+ export const RPS_PREFIX = 'rps';
2
+
3
+ export type RpsMove = 'rock' | 'paper' | 'scissors';
4
+
5
+ export const MOVE_LABELS: Record<RpsMove, string> = {
6
+ rock: '✊ 石头',
7
+ paper: '✋ 布',
8
+ scissors: '✌️ 剪刀',
9
+ };
10
+
11
+ const BEATS: Record<RpsMove, RpsMove> = {
12
+ rock: 'scissors',
13
+ paper: 'rock',
14
+ scissors: 'paper',
15
+ };
16
+
17
+ export function randomBotMove(): RpsMove {
18
+ const moves: RpsMove[] = ['rock', 'paper', 'scissors'];
19
+ return moves[Math.floor(Math.random() * moves.length)]!;
20
+ }
21
+
22
+ /** 1=玩家胜 2=机器人胜 0=平局 */
23
+ export function resolveRound(player: RpsMove, bot: RpsMove): 0 | 1 | 2 {
24
+ if (player === bot) return 0;
25
+ return BEATS[player] === bot ? 1 : 2;
26
+ }
27
+
28
+ export function parseMove(raw: string): RpsMove | null {
29
+ const t = raw.trim().toLowerCase();
30
+ const map: Record<string, RpsMove> = {
31
+ rock: 'rock',
32
+ paper: 'paper',
33
+ scissors: 'scissors',
34
+ 石头: 'rock',
35
+ 布: 'paper',
36
+ 剪刀: 'scissors',
37
+ r: 'rock',
38
+ p: 'paper',
39
+ s: 'scissors',
40
+ };
41
+ return map[t] ?? null;
42
+ }
43
+
44
+ export const WIN_TARGET = 3;
@@ -0,0 +1,127 @@
1
+ import type { Adapter, Message, Plugin } from 'zhin.js';
2
+ import type { RpsSessionRow } from './models.js';
3
+ import {
4
+ RPS_PREFIX,
5
+ WIN_TARGET,
6
+ randomBotMove,
7
+ resolveRound,
8
+ type RpsMove,
9
+ } from './engine.js';
10
+ import type { SessionService } from './session-service.js';
11
+ import { buildRpsView } from './view.js';
12
+
13
+ export async function sendOrEditView(
14
+ plugin: Plugin,
15
+ services: SessionService,
16
+ message: Message<any>,
17
+ session: RpsSessionRow,
18
+ lastRound?: { player: RpsMove; bot: RpsMove; result: 0 | 1 | 2 },
19
+ ): Promise<void> {
20
+ const content = buildRpsView(session, lastRound);
21
+ const adapter = plugin.root.inject(message.$adapter) as Adapter;
22
+
23
+ if (session.board_message_id) {
24
+ const msgId = await adapter.editMessage({
25
+ messageId: session.board_message_id,
26
+ context: String(message.$adapter),
27
+ endpoint: message.$endpoint,
28
+ id: message.$channel.id,
29
+ type: message.$channel.type,
30
+ content,
31
+ });
32
+ if (msgId !== session.board_message_id) {
33
+ await services.updateSession(session.id, { board_message_id: msgId });
34
+ }
35
+ return;
36
+ }
37
+
38
+ const msgId = await message.$reply?.(content);
39
+ if (msgId) await services.updateSession(session.id, { board_message_id: msgId });
40
+ }
41
+
42
+ export async function startGame(
43
+ plugin: Plugin,
44
+ services: SessionService,
45
+ message: Message<any>,
46
+ ): Promise<string | undefined> {
47
+ const ch = `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
48
+ const active = await services.getActiveByChannel(ch);
49
+ if (active) {
50
+ if (active.player_id === message.$sender.id) {
51
+ return '你已有进行中的猜拳,发送「猜拳 继续」刷新,或「猜拳 放弃」。';
52
+ }
53
+ return `本频道 ${active.player_name} 正在猜拳对决中。`;
54
+ }
55
+ const session = await services.createSession(message);
56
+ await sendOrEditView(plugin, services, message, session);
57
+ return undefined;
58
+ }
59
+
60
+ export async function handleChoice(
61
+ plugin: Plugin,
62
+ services: SessionService,
63
+ message: Message<any>,
64
+ sessionId: string,
65
+ choiceId: string,
66
+ ): Promise<string | null> {
67
+ const session = await services.getById(sessionId);
68
+ if (!session) return '对局不存在。';
69
+ if (session.status !== 'active' && choiceId !== 'restart') {
70
+ return '对局已结束,请点击再来一局。';
71
+ }
72
+ if (session.player_id !== message.$sender.id) return '这是别人的对局。';
73
+
74
+ if (choiceId === 'restart') {
75
+ await services.updateSession(session.id, {
76
+ player_wins: 0,
77
+ bot_wins: 0,
78
+ round: 0,
79
+ status: 'active',
80
+ board_message_id: '',
81
+ });
82
+ const updated = (await services.getById(session.id))!;
83
+ await sendOrEditView(plugin, services, message, updated);
84
+ return null;
85
+ }
86
+
87
+ const player = choiceId as RpsMove;
88
+ if (!['rock', 'paper', 'scissors'].includes(player)) return '无效出拳。';
89
+
90
+ const bot = randomBotMove();
91
+ const result = resolveRound(player, bot);
92
+ let playerWins = session.player_wins;
93
+ let botWins = session.bot_wins;
94
+ if (result === 1) playerWins++;
95
+ if (result === 2) botWins++;
96
+
97
+ let status: RpsSessionRow['status'] = 'active';
98
+ if (playerWins >= WIN_TARGET) status = 'won';
99
+ else if (botWins >= WIN_TARGET) status = 'lost';
100
+
101
+ await services.updateSession(session.id, {
102
+ player_wins: playerWins,
103
+ bot_wins: botWins,
104
+ round: session.round + 1,
105
+ status,
106
+ });
107
+
108
+ const updated = (await services.getById(session.id))!;
109
+ await sendOrEditView(plugin, services, message, updated, { player, bot, result });
110
+ return null;
111
+ }
112
+
113
+ export async function continueGame(
114
+ plugin: Plugin,
115
+ services: SessionService,
116
+ message: Message<any>,
117
+ ): Promise<string> {
118
+ const session = await services.getActiveForUser(
119
+ `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`,
120
+ message.$sender.id,
121
+ );
122
+ if (!session) return '你没有进行中的猜拳,发送「猜拳 开始」。';
123
+ await sendOrEditView(plugin, services, message, session);
124
+ return '已刷新猜拳界面。';
125
+ }
126
+
127
+ export { RPS_PREFIX };
@@ -0,0 +1,30 @@
1
+ import { getPlugin } from 'zhin.js';
2
+ import { ensureGameHubService } from '@zhin.js/game-shared';
3
+ import { runRpsCommand, RPS_HELP } from './rps-command.js';
4
+ import type { SessionService } from './session-service.js';
5
+
6
+ export function registerRpsHub(getServices: () => SessionService | null): () => void {
7
+ const plugin = getPlugin();
8
+ ensureGameHubService(plugin);
9
+ return plugin.registerGame({
10
+ id: 'rps',
11
+ title: '猜拳对决',
12
+ icon: '✊',
13
+ description: '石头剪刀布,三局两胜',
14
+ commandPrefix: '猜拳',
15
+ quickStart: '开始',
16
+ aliases: ['rps'],
17
+ menus: [
18
+ { id: 'start', label: '🎮 开始对局', style: 'primary' },
19
+ { id: 'continue', label: '🔄 继续' },
20
+ { id: 'help', label: '📖 玩法说明' },
21
+ ],
22
+ runAction: async (actionId, ctx) => {
23
+ const services = getServices();
24
+ if (!services) return '猜拳需要启用 database 配置。';
25
+ return runRpsCommand(ctx.plugin, services, ctx.message, actionId);
26
+ },
27
+ });
28
+ }
29
+
30
+ export { RPS_HELP };
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { Cron, formatCompact, usePlugin, type DatabaseFeature } from 'zhin.js';
2
+ import { registerModels } from './models.js';
3
+ import {
4
+ createServices,
5
+ resolveGameDatabase,
6
+ type SessionService,
7
+ } from './session-service.js';
8
+ import { registerCommands, registerInteractive, registerTextFallback } from './commands.js';
9
+ import { registerRpsHub } from './hub-register.js';
10
+
11
+ const plugin = usePlugin();
12
+ const { logger, useContext, addCron } = plugin;
13
+
14
+ registerModels(plugin);
15
+
16
+ let services: SessionService | null = null;
17
+
18
+ useContext('database', (dbFeature: DatabaseFeature) => {
19
+ services = createServices(resolveGameDatabase(dbFeature));
20
+ logger.info(formatCompact({ 模块: '猜拳', 数据模型: '已就绪' }));
21
+ });
22
+
23
+ registerRpsHub(() => services);
24
+ registerCommands(plugin, () => services);
25
+ registerInteractive(plugin, () => services);
26
+ registerTextFallback(plugin, () => services);
27
+
28
+ addCron(
29
+ new Cron('0 */15 * * * *', async () => {
30
+ if (!services) return;
31
+ const n = await services.abortStale(30 * 60 * 1000);
32
+ if (n > 0) logger.debug(formatCompact({ 猜拳: '清理超时局', count: n }));
33
+ }),
34
+ );
35
+
36
+ logger.info(formatCompact({ 模块: '猜拳', 状态: '已加载' }));
package/src/models.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { Models, Plugin } from 'zhin.js';
2
+
3
+ export type RpsSessionStatus = 'active' | 'won' | 'lost' | 'aborted';
4
+
5
+ declare module 'zhin.js' {
6
+ interface Models {
7
+ rps_sessions: {
8
+ id: string;
9
+ adapter: string;
10
+ endpoint: string;
11
+ channel_type: string;
12
+ channel_id: string;
13
+ channel_key: string;
14
+ player_id: string;
15
+ player_name: string;
16
+ player_wins: number;
17
+ bot_wins: number;
18
+ round: number;
19
+ status: RpsSessionStatus;
20
+ board_message_id: string;
21
+ updated_at: number;
22
+ created_at: number;
23
+ };
24
+ }
25
+ }
26
+
27
+ export type RpsSessionRow = Models['rps_sessions'];
28
+
29
+ export function registerModels(plugin: Plugin): void {
30
+ plugin.defineModel('rps_sessions', {
31
+ id: { type: 'text', primary: true },
32
+ adapter: { type: 'text', nullable: false },
33
+ endpoint: { type: 'text', nullable: false },
34
+ channel_type: { type: 'text', nullable: false },
35
+ channel_id: { type: 'text', nullable: false },
36
+ channel_key: { type: 'text', nullable: false },
37
+ player_id: { type: 'text', nullable: false },
38
+ player_name: { type: 'text', default: '' },
39
+ player_wins: { type: 'integer', default: 0 },
40
+ bot_wins: { type: 'integer', default: 0 },
41
+ round: { type: 'integer', default: 0 },
42
+ status: { type: 'text', default: 'active' },
43
+ board_message_id: { type: 'text', default: '' },
44
+ updated_at: { type: 'integer', default: 0 },
45
+ created_at: { type: 'integer', default: 0 },
46
+ });
47
+ }
@@ -0,0 +1,45 @@
1
+ import type { Message, Plugin } from 'zhin.js';
2
+ import { channelKey } from '@zhin.js/game-shared';
3
+ import { continueGame, startGame } from './game-flow.js';
4
+ import type { SessionService } from './session-service.js';
5
+
6
+ export const RPS_HELP = [
7
+ '✊✋✌️ 猜拳对决(三局两胜)',
8
+ '猜拳 / rps — 帮助',
9
+ '猜拳 开始 — 新对局',
10
+ '猜拳 继续 — 刷新界面',
11
+ '猜拳 放弃 — 结束对局',
12
+ ].join('\n');
13
+
14
+ export async function runRpsCommand(
15
+ plugin: Plugin,
16
+ services: SessionService,
17
+ message: Message<any>,
18
+ action: string,
19
+ ): Promise<string | undefined> {
20
+ const ch = channelKey(message);
21
+ const userId = message.$sender.id;
22
+
23
+ if (!action || action === 'help') {
24
+ const active = await services.getActiveByChannel(ch);
25
+ const lines = [RPS_HELP, ''];
26
+ if (active) {
27
+ lines.push(`进行中:${active.player_name} · ${active.player_wins}:${active.bot_wins}`);
28
+ } else {
29
+ lines.push('暂无对局,发送「猜拳 开始」或从游戏大厅进入。');
30
+ }
31
+ return lines.join('\n');
32
+ }
33
+
34
+ if (action === 'start') return startGame(plugin, services, message);
35
+ if (action === 'continue') return continueGame(plugin, services, message);
36
+
37
+ if (action === 'quit') {
38
+ const row = await services.getActiveForUser(ch, userId);
39
+ if (!row) return '你没有进行中的猜拳。';
40
+ await services.updateSession(row.id, { status: 'aborted' });
41
+ return '已放弃猜拳对局。';
42
+ }
43
+
44
+ return `未知子命令:${action}\n\n${RPS_HELP}`;
45
+ }
@@ -0,0 +1,92 @@
1
+ import type { Database, DatabaseFeature, Message, Models, RelatedModel } from 'zhin.js';
2
+ import { channelKey, generateSessionId } from '@zhin.js/game-shared';
3
+ import type { RpsSessionRow } from './models.js';
4
+
5
+ export type RpsDatabase = Database<unknown, Models, string>;
6
+
7
+ function getModel(db: RpsDatabase) {
8
+ const model = db.models.get('rps_sessions');
9
+ if (!model) throw new Error('rps_sessions not registered');
10
+ return model as RelatedModel<unknown, Models, 'rps_sessions'>;
11
+ }
12
+
13
+ export class SessionService {
14
+ constructor(private readonly db: RpsDatabase) {}
15
+
16
+ async getActiveByChannel(channel: string): Promise<RpsSessionRow | null> {
17
+ const rows = await getModel(this.db).findAll({ channel_key: channel, status: 'active' });
18
+ return rows[0] ?? null;
19
+ }
20
+
21
+ async getActiveForUser(channel: string, userId: string): Promise<RpsSessionRow | null> {
22
+ const row = await this.getActiveByChannel(channel);
23
+ if (!row || row.player_id !== userId) return null;
24
+ return row;
25
+ }
26
+
27
+ async getById(id: string): Promise<RpsSessionRow | null> {
28
+ return getModel(this.db).findOne({ id });
29
+ }
30
+
31
+ async getActiveByBoardMessageId(messageId: string): Promise<RpsSessionRow | null> {
32
+ if (!messageId) return null;
33
+ const rows = await getModel(this.db).findAll({ status: 'active' });
34
+ for (const row of rows) {
35
+ const stored = row.board_message_id;
36
+ if (!stored) continue;
37
+ if (stored === messageId || stored.endsWith(`:${messageId}`)) return row;
38
+ const tail = stored.split(':').pop();
39
+ if (tail && messageId.endsWith(tail)) return row;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ async createSession(message: Message<any>): Promise<RpsSessionRow> {
45
+ const now = Date.now();
46
+ const row: RpsSessionRow = {
47
+ id: generateSessionId(),
48
+ adapter: String(message.$adapter),
49
+ endpoint: message.$endpoint,
50
+ channel_type: message.$channel.type,
51
+ channel_id: message.$channel.id,
52
+ channel_key: channelKey(message),
53
+ player_id: message.$sender.id,
54
+ player_name: message.$sender.name?.trim() || message.$sender.id,
55
+ player_wins: 0,
56
+ bot_wins: 0,
57
+ round: 0,
58
+ status: 'active',
59
+ board_message_id: '',
60
+ updated_at: now,
61
+ created_at: now,
62
+ };
63
+ await getModel(this.db).create(row);
64
+ return row;
65
+ }
66
+
67
+ async updateSession(id: string, patch: Partial<RpsSessionRow>): Promise<void> {
68
+ await getModel(this.db).updateWhere({ id }, { ...patch, updated_at: Date.now() });
69
+ }
70
+
71
+ async abortStale(idleMs: number): Promise<number> {
72
+ const cutoff = Date.now() - idleMs;
73
+ const model = getModel(this.db);
74
+ const rows = await model.findAll({ status: 'active' });
75
+ let n = 0;
76
+ for (const row of rows) {
77
+ if (row.updated_at < cutoff) {
78
+ await model.updateWhere({ id: row.id }, { status: 'aborted', updated_at: Date.now() });
79
+ n++;
80
+ }
81
+ }
82
+ return n;
83
+ }
84
+ }
85
+
86
+ export function createServices(db: RpsDatabase): SessionService {
87
+ return new SessionService(db);
88
+ }
89
+
90
+ export function resolveGameDatabase(feature: DatabaseFeature): RpsDatabase {
91
+ return feature.db;
92
+ }
package/src/view.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { buildChoiceKeyboard } from '@zhin.js/game-shared';
2
+ import type { SendContent } from 'zhin.js';
3
+ import type { RpsSessionRow } from './models.js';
4
+ import { MOVE_LABELS, RPS_PREFIX, WIN_TARGET, type RpsMove } from './engine.js';
5
+
6
+ export function buildRpsView(
7
+ session: RpsSessionRow,
8
+ lastRound?: { player: RpsMove; bot: RpsMove; result: 0 | 1 | 2 },
9
+ ): SendContent {
10
+ const terminal = session.status !== 'active';
11
+ const lines = [
12
+ '✊✋✌️ **猜拳对决**',
13
+ '',
14
+ `比分 · 你 **${session.player_wins}** : **${session.bot_wins}** 机器人(先 ${WIN_TARGET} 胜)`,
15
+ ];
16
+
17
+ if (lastRound) {
18
+ lines.push('');
19
+ lines.push(`你出 ${MOVE_LABELS[lastRound.player]},机器人出 ${MOVE_LABELS[lastRound.bot]}`);
20
+ lines.push(
21
+ lastRound.result === 0
22
+ ? '🤝 平局!'
23
+ : lastRound.result === 1
24
+ ? '🎉 你赢了这一局!'
25
+ : '😅 机器人赢了这一局。',
26
+ );
27
+ }
28
+
29
+ if (terminal) {
30
+ lines.push('');
31
+ if (session.status === 'won') lines.push('🏆 **你赢得比赛!**');
32
+ else if (session.status === 'lost') lines.push('💀 **机器人赢得比赛。**');
33
+ } else if (!lastRound) {
34
+ lines.push('', '出拳吧!');
35
+ } else {
36
+ lines.push('', '继续出拳:');
37
+ }
38
+
39
+ const choices = terminal
40
+ ? [{ id: 'restart', label: '🔄 再来一局', style: 'primary' as const }]
41
+ : (['rock', 'paper', 'scissors'] as RpsMove[]).map((m) => ({
42
+ id: m,
43
+ label: MOVE_LABELS[m],
44
+ style: 'secondary' as const,
45
+ }));
46
+
47
+ return buildChoiceKeyboard({
48
+ gamePrefix: RPS_PREFIX,
49
+ sessionId: session.id,
50
+ narrative: lines.join('\n'),
51
+ choices,
52
+ terminal,
53
+ buttonsPerRow: 3,
54
+ fallbackHint: '回复数字出拳(1石头 2布 3剪刀)',
55
+ });
56
+ }