@zhin.js/plugin-blackjack 0.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 (48) hide show
  1. package/README.md +9 -0
  2. package/lib/bj-command.d.ts +5 -0
  3. package/lib/bj-command.d.ts.map +1 -0
  4. package/lib/bj-command.js +21 -0
  5. package/lib/bj-command.js.map +1 -0
  6. package/lib/commands.d.ts +6 -0
  7. package/lib/commands.d.ts.map +1 -0
  8. package/lib/commands.js +98 -0
  9. package/lib/commands.js.map +1 -0
  10. package/lib/engine.d.ts +14 -0
  11. package/lib/engine.d.ts.map +1 -0
  12. package/lib/engine.js +65 -0
  13. package/lib/engine.js.map +1 -0
  14. package/lib/game-flow.d.ts +8 -0
  15. package/lib/game-flow.d.ts.map +1 -0
  16. package/lib/game-flow.js +118 -0
  17. package/lib/game-flow.js.map +1 -0
  18. package/lib/hub-register.d.ts +3 -0
  19. package/lib/hub-register.d.ts.map +1 -0
  20. package/lib/hub-register.js +29 -0
  21. package/lib/hub-register.js.map +1 -0
  22. package/lib/index.d.ts +2 -0
  23. package/lib/index.d.ts.map +1 -0
  24. package/lib/index.js +26 -0
  25. package/lib/index.js.map +1 -0
  26. package/lib/models.d.ts +26 -0
  27. package/lib/models.d.ts.map +1 -0
  28. package/lib/models.js +20 -0
  29. package/lib/models.js.map +1 -0
  30. package/lib/session-service.d.ts +19 -0
  31. package/lib/session-service.d.ts.map +1 -0
  32. package/lib/session-service.js +108 -0
  33. package/lib/session-service.js.map +1 -0
  34. package/lib/view.d.ts +7 -0
  35. package/lib/view.d.ts.map +1 -0
  36. package/lib/view.js +64 -0
  37. package/lib/view.js.map +1 -0
  38. package/package.json +37 -0
  39. package/plugin.yml +2 -0
  40. package/src/bj-command.ts +26 -0
  41. package/src/commands.ts +117 -0
  42. package/src/engine.ts +67 -0
  43. package/src/game-flow.ts +164 -0
  44. package/src/hub-register.ts +28 -0
  45. package/src/index.ts +36 -0
  46. package/src/models.ts +47 -0
  47. package/src/session-service.ts +118 -0
  48. package/src/view.ts +70 -0
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 { registerBjHub } 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点', 数据模型: '已就绪' }));
21
+ });
22
+
23
+ registerBjHub(() => 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({ '21点': '清理超时局', count: n }));
33
+ }),
34
+ );
35
+
36
+ logger.info(formatCompact({ 模块: '21点', 状态: '已加载' }));
package/src/models.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { Models, Plugin } from 'zhin.js';
2
+
3
+ export type BjSessionStatus = 'active' | 'won' | 'lost' | 'draw' | 'aborted';
4
+
5
+ declare module 'zhin.js' {
6
+ interface Models {
7
+ bj_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
+ deck_json: string;
17
+ player_cards_json: string;
18
+ dealer_cards_json: string;
19
+ board_message_id: string;
20
+ status: BjSessionStatus;
21
+ updated_at: number;
22
+ created_at: number;
23
+ };
24
+ }
25
+ }
26
+
27
+ export type BjSessionRow = Models['bj_sessions'];
28
+
29
+ export function registerModels(plugin: Plugin): void {
30
+ plugin.defineModel('bj_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
+ deck_json: { type: 'text', default: '[]' },
40
+ player_cards_json: { type: 'text', default: '[]' },
41
+ dealer_cards_json: { type: 'text', default: '[]' },
42
+ board_message_id: { type: 'text', default: '' },
43
+ status: { type: 'text', default: 'active' },
44
+ updated_at: { type: 'integer', default: 0 },
45
+ created_at: { type: 'integer', default: 0 },
46
+ });
47
+ }
@@ -0,0 +1,118 @@
1
+ import type { Database, DatabaseFeature, Message, Models, RelatedModel } from 'zhin.js';
2
+ import { channelKey, generateSessionId } from '@zhin.js/game-shared';
3
+ import type { BjSessionRow } from './models.js';
4
+ import { freshDeck } from './engine.js';
5
+
6
+ export type BjDatabase = Database<unknown, Models, string>;
7
+
8
+ function getModel(db: BjDatabase): RelatedModel<unknown, Models, 'bj_sessions'> {
9
+ const model = db.models.get('bj_sessions');
10
+ if (!model) throw new Error('bj_sessions not registered');
11
+ return model as RelatedModel<unknown, Models, 'bj_sessions'>;
12
+ }
13
+
14
+ export function resolveGameDatabase(feature: DatabaseFeature): BjDatabase {
15
+ return feature.db;
16
+ }
17
+
18
+ export class SessionService {
19
+ constructor(private readonly db: BjDatabase) {}
20
+
21
+ async createSession(message: Message<any>): Promise<BjSessionRow> {
22
+ const deck = freshDeck();
23
+ const playerCards = [deck.pop()!, deck.pop()!];
24
+ const dealerCards = [deck.pop()!, deck.pop()!];
25
+ const now = Date.now();
26
+ const row: BjSessionRow = {
27
+ id: generateSessionId(),
28
+ adapter: String(message.$adapter),
29
+ endpoint: message.$endpoint,
30
+ channel_type: message.$channel.type,
31
+ channel_id: message.$channel.id,
32
+ channel_key: channelKey(message),
33
+ player_id: message.$sender.id,
34
+ player_name: String(message.$sender.name ?? message.$sender.id),
35
+ deck_json: JSON.stringify(deck),
36
+ player_cards_json: JSON.stringify(playerCards),
37
+ dealer_cards_json: JSON.stringify(dealerCards),
38
+ board_message_id: '',
39
+ status: 'active',
40
+ updated_at: now,
41
+ created_at: now,
42
+ };
43
+ await getModel(this.db).create(row);
44
+ return row;
45
+ }
46
+
47
+ async getById(id: string): Promise<BjSessionRow | null> {
48
+ return getModel(this.db).findOne({ id });
49
+ }
50
+
51
+ async getActiveForUser(channelKeyValue: string, userId: string): Promise<BjSessionRow | null> {
52
+ const rows = await getModel(this.db).findAll({
53
+ channel_key: channelKeyValue,
54
+ player_id: userId,
55
+ status: 'active',
56
+ });
57
+ return rows[0] ?? null;
58
+ }
59
+
60
+ async getActiveByChannel(channelKeyValue: string): Promise<BjSessionRow | null> {
61
+ const rows = await getModel(this.db).findAll({
62
+ channel_key: channelKeyValue,
63
+ status: 'active',
64
+ });
65
+ return rows[0] ?? null;
66
+ }
67
+
68
+ async getActiveByBoardMessageId(messageId: string): Promise<BjSessionRow | null> {
69
+ const rows = await getModel(this.db).findAll({
70
+ board_message_id: messageId,
71
+ status: 'active',
72
+ });
73
+ return rows[0] ?? null;
74
+ }
75
+
76
+ async updateSession(id: string, patch: Partial<BjSessionRow>): Promise<void> {
77
+ await getModel(this.db).updateWhere({ id }, { ...patch, updated_at: Date.now() });
78
+ }
79
+
80
+ async abortStale(maxAgeMs: number): Promise<number> {
81
+ const cutoff = Date.now() - maxAgeMs;
82
+ const model = getModel(this.db);
83
+ const rows = await model.findAll({ status: 'active' });
84
+ let n = 0;
85
+ for (const row of rows) {
86
+ if (row.updated_at < cutoff) {
87
+ await model.updateWhere({ id: row.id }, { status: 'aborted', updated_at: Date.now() });
88
+ n++;
89
+ }
90
+ }
91
+ return n;
92
+ }
93
+ }
94
+
95
+ export function createServices(db: BjDatabase): SessionService {
96
+ return new SessionService(db);
97
+ }
98
+
99
+ function parseJsonStringArray(value: string | string[] | unknown): string[] {
100
+ if (Array.isArray(value)) return value.filter((x): x is string => typeof x === 'string');
101
+ if (typeof value !== 'string' || !value) return [];
102
+ try {
103
+ const parsed = JSON.parse(value);
104
+ return Array.isArray(parsed)
105
+ ? parsed.filter((x): x is string => typeof x === 'string')
106
+ : [];
107
+ } catch {
108
+ return [];
109
+ }
110
+ }
111
+
112
+ export function parseCards(json: string | string[]): string[] {
113
+ return parseJsonStringArray(json);
114
+ }
115
+
116
+ export function parseDeck(json: string | string[]): string[] {
117
+ return parseJsonStringArray(json);
118
+ }
package/src/view.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { buildChoiceKeyboard } from '@zhin.js/game-shared';
2
+ import type { SendContent } from 'zhin.js';
3
+ import { BJ_PREFIX, formatHand, handValue, isBlackjack, TARGET } from './engine.js';
4
+ import type { BjSessionRow } from './models.js';
5
+ import { parseCards } from './session-service.js';
6
+
7
+ export function buildBjView(
8
+ session: BjSessionRow,
9
+ terminal = false,
10
+ revealDealer = false,
11
+ channelType?: string,
12
+ ): SendContent {
13
+ const player = parseCards(session.player_cards_json);
14
+ const dealer = parseCards(session.dealer_cards_json);
15
+ const lines = [
16
+ '🃏 **21 点**',
17
+ '',
18
+ `你的牌:${formatHand(player)}`,
19
+ `庄家:${revealDealer || terminal ? formatHand(dealer) : formatHand(dealer, true)}`,
20
+ '',
21
+ ];
22
+
23
+ if (terminal) {
24
+ if (session.status === 'won') lines.push('🎉 **你赢了!**');
25
+ else if (session.status === 'lost') lines.push('💀 **你输了**');
26
+ else lines.push('🤝 **平局**');
27
+ lines.push('', '点击「再来一局」或发送 `/21点 开始`');
28
+ } else {
29
+ lines.push(`目标:尽量接近 ${TARGET} 且不超过。`);
30
+ }
31
+
32
+ const choices = terminal
33
+ ? [{ id: 'restart', label: '🔄 再来一局', style: 'primary' as const, keepEnabledWhenTerminal: true }]
34
+ : [
35
+ { id: 'hit', label: '➕ 要牌', style: 'primary' as const },
36
+ { id: 'stand', label: '✋ 停牌', style: 'secondary' as const },
37
+ ];
38
+
39
+ return buildChoiceKeyboard({
40
+ gamePrefix: BJ_PREFIX,
41
+ sessionId: session.id,
42
+ narrative: lines.join('\n'),
43
+ choices,
44
+ terminal,
45
+ fallbackHint: terminal ? '回复 1 再来一局' : '1 要牌 · 2 停牌',
46
+ interactionProfile: terminal ? 'terminal' : 'gameplay',
47
+ channelType,
48
+ });
49
+ }
50
+
51
+ export function checkNaturalBlackjack(session: BjSessionRow): boolean {
52
+ const player = parseCards(session.player_cards_json);
53
+ const dealer = parseCards(session.dealer_cards_json);
54
+ return isBlackjack(player) || isBlackjack(dealer);
55
+ }
56
+
57
+ export function naturalOutcome(session: BjSessionRow): 'won' | 'lost' | 'draw' | null {
58
+ const player = parseCards(session.player_cards_json);
59
+ const dealer = parseCards(session.dealer_cards_json);
60
+ const pbj = isBlackjack(player);
61
+ const dbj = isBlackjack(dealer);
62
+ if (!pbj && !dbj) return null;
63
+ if (pbj && dbj) return 'draw';
64
+ if (pbj) return 'won';
65
+ return 'lost';
66
+ }
67
+
68
+ export function playerBust(session: BjSessionRow): boolean {
69
+ return handValue(parseCards(session.player_cards_json)) > TARGET;
70
+ }