@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.
- package/README.md +9 -0
- package/lib/bj-command.d.ts +5 -0
- package/lib/bj-command.d.ts.map +1 -0
- package/lib/bj-command.js +21 -0
- package/lib/bj-command.js.map +1 -0
- package/lib/commands.d.ts +6 -0
- package/lib/commands.d.ts.map +1 -0
- package/lib/commands.js +98 -0
- package/lib/commands.js.map +1 -0
- package/lib/engine.d.ts +14 -0
- package/lib/engine.d.ts.map +1 -0
- package/lib/engine.js +65 -0
- package/lib/engine.js.map +1 -0
- package/lib/game-flow.d.ts +8 -0
- package/lib/game-flow.d.ts.map +1 -0
- package/lib/game-flow.js +118 -0
- package/lib/game-flow.js.map +1 -0
- package/lib/hub-register.d.ts +3 -0
- package/lib/hub-register.d.ts.map +1 -0
- package/lib/hub-register.js +29 -0
- package/lib/hub-register.js.map +1 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +26 -0
- package/lib/index.js.map +1 -0
- package/lib/models.d.ts +26 -0
- package/lib/models.d.ts.map +1 -0
- package/lib/models.js +20 -0
- package/lib/models.js.map +1 -0
- package/lib/session-service.d.ts +19 -0
- package/lib/session-service.d.ts.map +1 -0
- package/lib/session-service.js +108 -0
- package/lib/session-service.js.map +1 -0
- package/lib/view.d.ts +7 -0
- package/lib/view.d.ts.map +1 -0
- package/lib/view.js +64 -0
- package/lib/view.js.map +1 -0
- package/package.json +37 -0
- package/plugin.yml +2 -0
- package/src/bj-command.ts +26 -0
- package/src/commands.ts +117 -0
- package/src/engine.ts +67 -0
- package/src/game-flow.ts +164 -0
- package/src/hub-register.ts +28 -0
- package/src/index.ts +36 -0
- package/src/models.ts +47 -0
- package/src/session-service.ts +118 -0
- package/src/view.ts +70 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { channelKey, generateSessionId } from '@zhin.js/game-shared';
|
|
2
|
+
import { freshDeck } from './engine.js';
|
|
3
|
+
function getModel(db) {
|
|
4
|
+
const model = db.models.get('bj_sessions');
|
|
5
|
+
if (!model)
|
|
6
|
+
throw new Error('bj_sessions not registered');
|
|
7
|
+
return model;
|
|
8
|
+
}
|
|
9
|
+
export function resolveGameDatabase(feature) {
|
|
10
|
+
return feature.db;
|
|
11
|
+
}
|
|
12
|
+
export class SessionService {
|
|
13
|
+
db;
|
|
14
|
+
constructor(db) {
|
|
15
|
+
this.db = db;
|
|
16
|
+
}
|
|
17
|
+
async createSession(message) {
|
|
18
|
+
const deck = freshDeck();
|
|
19
|
+
const playerCards = [deck.pop(), deck.pop()];
|
|
20
|
+
const dealerCards = [deck.pop(), deck.pop()];
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
const row = {
|
|
23
|
+
id: generateSessionId(),
|
|
24
|
+
adapter: String(message.$adapter),
|
|
25
|
+
endpoint: message.$endpoint,
|
|
26
|
+
channel_type: message.$channel.type,
|
|
27
|
+
channel_id: message.$channel.id,
|
|
28
|
+
channel_key: channelKey(message),
|
|
29
|
+
player_id: message.$sender.id,
|
|
30
|
+
player_name: String(message.$sender.name ?? message.$sender.id),
|
|
31
|
+
deck_json: JSON.stringify(deck),
|
|
32
|
+
player_cards_json: JSON.stringify(playerCards),
|
|
33
|
+
dealer_cards_json: JSON.stringify(dealerCards),
|
|
34
|
+
board_message_id: '',
|
|
35
|
+
status: 'active',
|
|
36
|
+
updated_at: now,
|
|
37
|
+
created_at: now,
|
|
38
|
+
};
|
|
39
|
+
await getModel(this.db).create(row);
|
|
40
|
+
return row;
|
|
41
|
+
}
|
|
42
|
+
async getById(id) {
|
|
43
|
+
return getModel(this.db).findOne({ id });
|
|
44
|
+
}
|
|
45
|
+
async getActiveForUser(channelKeyValue, userId) {
|
|
46
|
+
const rows = await getModel(this.db).findAll({
|
|
47
|
+
channel_key: channelKeyValue,
|
|
48
|
+
player_id: userId,
|
|
49
|
+
status: 'active',
|
|
50
|
+
});
|
|
51
|
+
return rows[0] ?? null;
|
|
52
|
+
}
|
|
53
|
+
async getActiveByChannel(channelKeyValue) {
|
|
54
|
+
const rows = await getModel(this.db).findAll({
|
|
55
|
+
channel_key: channelKeyValue,
|
|
56
|
+
status: 'active',
|
|
57
|
+
});
|
|
58
|
+
return rows[0] ?? null;
|
|
59
|
+
}
|
|
60
|
+
async getActiveByBoardMessageId(messageId) {
|
|
61
|
+
const rows = await getModel(this.db).findAll({
|
|
62
|
+
board_message_id: messageId,
|
|
63
|
+
status: 'active',
|
|
64
|
+
});
|
|
65
|
+
return rows[0] ?? null;
|
|
66
|
+
}
|
|
67
|
+
async updateSession(id, patch) {
|
|
68
|
+
await getModel(this.db).updateWhere({ id }, { ...patch, updated_at: Date.now() });
|
|
69
|
+
}
|
|
70
|
+
async abortStale(maxAgeMs) {
|
|
71
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
72
|
+
const model = getModel(this.db);
|
|
73
|
+
const rows = await model.findAll({ status: 'active' });
|
|
74
|
+
let n = 0;
|
|
75
|
+
for (const row of rows) {
|
|
76
|
+
if (row.updated_at < cutoff) {
|
|
77
|
+
await model.updateWhere({ id: row.id }, { status: 'aborted', updated_at: Date.now() });
|
|
78
|
+
n++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return n;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function createServices(db) {
|
|
85
|
+
return new SessionService(db);
|
|
86
|
+
}
|
|
87
|
+
function parseJsonStringArray(value) {
|
|
88
|
+
if (Array.isArray(value))
|
|
89
|
+
return value.filter((x) => typeof x === 'string');
|
|
90
|
+
if (typeof value !== 'string' || !value)
|
|
91
|
+
return [];
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(value);
|
|
94
|
+
return Array.isArray(parsed)
|
|
95
|
+
? parsed.filter((x) => typeof x === 'string')
|
|
96
|
+
: [];
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function parseCards(json) {
|
|
103
|
+
return parseJsonStringArray(json);
|
|
104
|
+
}
|
|
105
|
+
export function parseDeck(json) {
|
|
106
|
+
return parseJsonStringArray(json);
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=session-service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-service.js","sourceRoot":"","sources":["../src/session-service.ts"],"names":[],"mappings":"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"}
|
package/lib/view.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SendContent } from 'zhin.js';
|
|
2
|
+
import type { BjSessionRow } from './models.js';
|
|
3
|
+
export declare function buildBjView(session: BjSessionRow, terminal?: boolean, revealDealer?: boolean, channelType?: string): SendContent;
|
|
4
|
+
export declare function checkNaturalBlackjack(session: BjSessionRow): boolean;
|
|
5
|
+
export declare function naturalOutcome(session: BjSessionRow): 'won' | 'lost' | 'draw' | null;
|
|
6
|
+
export declare function playerBust(session: BjSessionRow): boolean;
|
|
7
|
+
//# sourceMappingURL=view.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"view.d.ts","sourceRoot":"","sources":["../src/view.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAGhD,wBAAgB,WAAW,CACzB,OAAO,EAAE,YAAY,EACrB,QAAQ,UAAQ,EAChB,YAAY,UAAQ,EACpB,WAAW,CAAC,EAAE,MAAM,GACnB,WAAW,CAqCb;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAIpE;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE,YAAY,GAAG,KAAK,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,CASpF;AAED,wBAAgB,UAAU,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAEzD"}
|
package/lib/view.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { buildChoiceKeyboard } from '@zhin.js/game-shared';
|
|
2
|
+
import { BJ_PREFIX, formatHand, handValue, isBlackjack, TARGET } from './engine.js';
|
|
3
|
+
import { parseCards } from './session-service.js';
|
|
4
|
+
export function buildBjView(session, terminal = false, revealDealer = false, channelType) {
|
|
5
|
+
const player = parseCards(session.player_cards_json);
|
|
6
|
+
const dealer = parseCards(session.dealer_cards_json);
|
|
7
|
+
const lines = [
|
|
8
|
+
'🃏 **21 点**',
|
|
9
|
+
'',
|
|
10
|
+
`你的牌:${formatHand(player)}`,
|
|
11
|
+
`庄家:${revealDealer || terminal ? formatHand(dealer) : formatHand(dealer, true)}`,
|
|
12
|
+
'',
|
|
13
|
+
];
|
|
14
|
+
if (terminal) {
|
|
15
|
+
if (session.status === 'won')
|
|
16
|
+
lines.push('🎉 **你赢了!**');
|
|
17
|
+
else if (session.status === 'lost')
|
|
18
|
+
lines.push('💀 **你输了**');
|
|
19
|
+
else
|
|
20
|
+
lines.push('🤝 **平局**');
|
|
21
|
+
lines.push('', '点击「再来一局」或发送 `/21点 开始`');
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
lines.push(`目标:尽量接近 ${TARGET} 且不超过。`);
|
|
25
|
+
}
|
|
26
|
+
const choices = terminal
|
|
27
|
+
? [{ id: 'restart', label: '🔄 再来一局', style: 'primary', keepEnabledWhenTerminal: true }]
|
|
28
|
+
: [
|
|
29
|
+
{ id: 'hit', label: '➕ 要牌', style: 'primary' },
|
|
30
|
+
{ id: 'stand', label: '✋ 停牌', style: 'secondary' },
|
|
31
|
+
];
|
|
32
|
+
return buildChoiceKeyboard({
|
|
33
|
+
gamePrefix: BJ_PREFIX,
|
|
34
|
+
sessionId: session.id,
|
|
35
|
+
narrative: lines.join('\n'),
|
|
36
|
+
choices,
|
|
37
|
+
terminal,
|
|
38
|
+
fallbackHint: terminal ? '回复 1 再来一局' : '1 要牌 · 2 停牌',
|
|
39
|
+
interactionProfile: terminal ? 'terminal' : 'gameplay',
|
|
40
|
+
channelType,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function checkNaturalBlackjack(session) {
|
|
44
|
+
const player = parseCards(session.player_cards_json);
|
|
45
|
+
const dealer = parseCards(session.dealer_cards_json);
|
|
46
|
+
return isBlackjack(player) || isBlackjack(dealer);
|
|
47
|
+
}
|
|
48
|
+
export function naturalOutcome(session) {
|
|
49
|
+
const player = parseCards(session.player_cards_json);
|
|
50
|
+
const dealer = parseCards(session.dealer_cards_json);
|
|
51
|
+
const pbj = isBlackjack(player);
|
|
52
|
+
const dbj = isBlackjack(dealer);
|
|
53
|
+
if (!pbj && !dbj)
|
|
54
|
+
return null;
|
|
55
|
+
if (pbj && dbj)
|
|
56
|
+
return 'draw';
|
|
57
|
+
if (pbj)
|
|
58
|
+
return 'won';
|
|
59
|
+
return 'lost';
|
|
60
|
+
}
|
|
61
|
+
export function playerBust(session) {
|
|
62
|
+
return handValue(parseCards(session.player_cards_json)) > TARGET;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=view.js.map
|
package/lib/view.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"view.js","sourceRoot":"","sources":["../src/view.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhin.js/plugin-blackjack",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Zhin.js 21 点(Blackjack)",
|
|
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,26 @@
|
|
|
1
|
+
import type { Message, Plugin } from 'zhin.js';
|
|
2
|
+
import { continueGame, handleChoice, startGame } from './game-flow.js';
|
|
3
|
+
import type { SessionService } from './session-service.js';
|
|
4
|
+
|
|
5
|
+
export const BJ_HELP = [
|
|
6
|
+
'🃏 **21 点**',
|
|
7
|
+
'',
|
|
8
|
+
'尽量让手牌点数接近 21 且不超过,比庄家大即获胜。',
|
|
9
|
+
'',
|
|
10
|
+
'• `/21点 开始` — 新局',
|
|
11
|
+
'• `/21点 继续` — 刷新界面',
|
|
12
|
+
'• 对局中点击 **要牌** / **停牌**',
|
|
13
|
+
].join('\n');
|
|
14
|
+
|
|
15
|
+
export async function runBjCommand(
|
|
16
|
+
plugin: Plugin,
|
|
17
|
+
services: SessionService,
|
|
18
|
+
message: Message<any>,
|
|
19
|
+
action: string,
|
|
20
|
+
): Promise<string | undefined> {
|
|
21
|
+
const a = action.trim().toLowerCase() || 'help';
|
|
22
|
+
if (a === 'help' || a === '帮助') return BJ_HELP;
|
|
23
|
+
if (a === 'start' || a === '开始') return startGame(plugin, services, message);
|
|
24
|
+
if (a === 'continue' || a === '继续') return continueGame(plugin, services, message);
|
|
25
|
+
return `未知操作「${action}」。发送 \`/21点 帮助\`。`;
|
|
26
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Message, MessageCommand, getActionFromMessage, type Plugin } from 'zhin.js';
|
|
2
|
+
import {
|
|
3
|
+
buildChoiceFallbackMap,
|
|
4
|
+
channelKey,
|
|
5
|
+
parseChoicePayload,
|
|
6
|
+
registerGameTextMiddleware,
|
|
7
|
+
resolveGameChoice,
|
|
8
|
+
resolveGameTextPayload,
|
|
9
|
+
} from '@zhin.js/game-shared';
|
|
10
|
+
import { BJ_PREFIX, handleChoice } from './game-flow.js';
|
|
11
|
+
import { runBjCommand } from './bj-command.js';
|
|
12
|
+
import type { SessionService } from './session-service.js';
|
|
13
|
+
|
|
14
|
+
const VALID_CHOICES = ['hit', 'stand', 'restart'] as const;
|
|
15
|
+
|
|
16
|
+
function registerPattern(
|
|
17
|
+
plugin: Plugin,
|
|
18
|
+
pattern: string,
|
|
19
|
+
desc: string,
|
|
20
|
+
getServices: () => SessionService | null,
|
|
21
|
+
): void {
|
|
22
|
+
plugin.addCommand(
|
|
23
|
+
new MessageCommand(pattern)
|
|
24
|
+
.desc(desc)
|
|
25
|
+
.action(async (message, result) => {
|
|
26
|
+
const services = getServices();
|
|
27
|
+
if (!services) return '21 点需要启用 database 配置。';
|
|
28
|
+
const raw = (result.params.action as string | undefined) ?? '';
|
|
29
|
+
return runBjCommand(plugin, services, message, raw);
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function registerCommands(plugin: Plugin, getServices: () => SessionService | null): void {
|
|
35
|
+
registerPattern(plugin, '/21点 [action:word]', '21 点(中文)', getServices);
|
|
36
|
+
registerPattern(plugin, '/bj [action:word]', 'Blackjack (English)', getServices);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function resolveChoice(
|
|
40
|
+
message: Message<any>,
|
|
41
|
+
services: SessionService,
|
|
42
|
+
): Promise<{ sessionId: string; choiceId: string } | null> {
|
|
43
|
+
return resolveGameChoice({
|
|
44
|
+
message,
|
|
45
|
+
gamePrefix: BJ_PREFIX,
|
|
46
|
+
validChoiceIds: VALID_CHOICES,
|
|
47
|
+
getById: (id) => services.getById(id),
|
|
48
|
+
getActiveForUser: (ch, uid) => services.getActiveForUser(ch, uid),
|
|
49
|
+
getByBoardMessageId: (mid) => services.getActiveByBoardMessageId(mid),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function registerInteractive(plugin: Plugin, getServices: () => SessionService | null): void {
|
|
54
|
+
plugin.registerInteractiveHandler(`${BJ_PREFIX}:`, async (message) => {
|
|
55
|
+
const services = getServices();
|
|
56
|
+
if (!services) return false;
|
|
57
|
+
const choice = await resolveChoice(message, services);
|
|
58
|
+
if (!choice) return false;
|
|
59
|
+
const err = await handleChoice(plugin, services, message, choice.sessionId, choice.choiceId);
|
|
60
|
+
if (err) await message.$reply?.(err);
|
|
61
|
+
return true;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function registerTextFallback(plugin: Plugin, getServices: () => SessionService | null): void {
|
|
66
|
+
registerGameTextMiddleware(plugin, async (message, next) => {
|
|
67
|
+
const services = getServices();
|
|
68
|
+
if (!services) return next();
|
|
69
|
+
|
|
70
|
+
const action = getActionFromMessage(message);
|
|
71
|
+
if (action?.payload.startsWith(`${BJ_PREFIX}:`)) return next();
|
|
72
|
+
|
|
73
|
+
const raw = message.$raw?.trim() ?? '';
|
|
74
|
+
const ch = channelKey(message);
|
|
75
|
+
|
|
76
|
+
const payloadFromText = resolveGameTextPayload(raw);
|
|
77
|
+
if (payloadFromText?.startsWith(`${BJ_PREFIX}:`)) {
|
|
78
|
+
const parsed = parseChoicePayload(payloadFromText, BJ_PREFIX);
|
|
79
|
+
if (parsed) {
|
|
80
|
+
const session = await services.getById(parsed.sessionId);
|
|
81
|
+
if (session?.channel_key === ch) {
|
|
82
|
+
const err = await handleChoice(plugin, services, message, parsed.sessionId, parsed.choiceId);
|
|
83
|
+
if (err) await message.$reply?.(err);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const session = await services.getActiveForUser(ch, message.$sender.id);
|
|
90
|
+
if (!session) return next();
|
|
91
|
+
|
|
92
|
+
if (session.status !== 'active') {
|
|
93
|
+
const map = buildChoiceFallbackMap(BJ_PREFIX, session.id, [
|
|
94
|
+
{ id: 'restart', label: '再来一局', keepEnabledWhenTerminal: true },
|
|
95
|
+
]);
|
|
96
|
+
const payload = resolveGameTextPayload(raw, map);
|
|
97
|
+
const parsed = payload ? parseChoicePayload(payload, BJ_PREFIX) : null;
|
|
98
|
+
if (parsed?.choiceId === 'restart') {
|
|
99
|
+
const err = await handleChoice(plugin, services, message, parsed.sessionId, 'restart');
|
|
100
|
+
if (err) await message.$reply?.(err);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
return next();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const map = buildChoiceFallbackMap(BJ_PREFIX, session.id, [
|
|
107
|
+
{ id: 'hit', label: '要牌' },
|
|
108
|
+
{ id: 'stand', label: '停牌' },
|
|
109
|
+
]);
|
|
110
|
+
const payload = resolveGameTextPayload(raw, map);
|
|
111
|
+
const parsed = payload ? parseChoicePayload(payload, BJ_PREFIX) : null;
|
|
112
|
+
if (!parsed || parsed.sessionId !== session.id) return next();
|
|
113
|
+
|
|
114
|
+
const err = await handleChoice(plugin, services, message, session.id, parsed.choiceId);
|
|
115
|
+
if (err) await message.$reply?.(err);
|
|
116
|
+
}, 'bj:text');
|
|
117
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const BJ_PREFIX = 'bj';
|
|
2
|
+
export const TARGET = 21;
|
|
3
|
+
|
|
4
|
+
const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'] as const;
|
|
5
|
+
const SUITS = ['♠', '♥', '♦', '♣'] as const;
|
|
6
|
+
|
|
7
|
+
export type Card = `${typeof RANKS[number]}${typeof SUITS[number]}`;
|
|
8
|
+
|
|
9
|
+
export function freshDeck(): Card[] {
|
|
10
|
+
const deck: Card[] = [];
|
|
11
|
+
for (const r of RANKS) {
|
|
12
|
+
for (const s of SUITS) {
|
|
13
|
+
deck.push(`${r}${s}` as Card);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
for (let i = deck.length - 1; i > 0; i--) {
|
|
17
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
18
|
+
[deck[i], deck[j]] = [deck[j]!, deck[i]!];
|
|
19
|
+
}
|
|
20
|
+
return deck;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function cardValue(rank: string): number {
|
|
24
|
+
if (rank === 'A') return 11;
|
|
25
|
+
if (rank === 'J' || rank === 'Q' || rank === 'K') return 10;
|
|
26
|
+
return Number(rank);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function handValue(cards: readonly string[]): number {
|
|
30
|
+
let total = 0;
|
|
31
|
+
let aces = 0;
|
|
32
|
+
for (const c of cards) {
|
|
33
|
+
const rank = c.slice(0, c.length - 1);
|
|
34
|
+
if (rank === 'A') aces++;
|
|
35
|
+
total += cardValue(rank);
|
|
36
|
+
}
|
|
37
|
+
while (total > TARGET && aces > 0) {
|
|
38
|
+
total -= 10;
|
|
39
|
+
aces--;
|
|
40
|
+
}
|
|
41
|
+
return total;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatHand(cards: readonly string[], hideSecond = false): string {
|
|
45
|
+
if (hideSecond && cards.length >= 2) {
|
|
46
|
+
return `${cards[0]} 🂠 (${handValue([cards[0]!])})`;
|
|
47
|
+
}
|
|
48
|
+
return `${cards.join(' ')} (${handValue(cards)})`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isBlackjack(cards: readonly string[]): boolean {
|
|
52
|
+
return cards.length === 2 && handValue(cards) === TARGET;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function dealerShouldHit(cards: readonly string[]): boolean {
|
|
56
|
+
return handValue(cards) < 17;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function compareHands(player: readonly string[], dealer: readonly string[]): 'won' | 'lost' | 'draw' {
|
|
60
|
+
const pv = handValue(player);
|
|
61
|
+
const dv = handValue(dealer);
|
|
62
|
+
if (pv > TARGET) return 'lost';
|
|
63
|
+
if (dv > TARGET) return 'won';
|
|
64
|
+
if (pv > dv) return 'won';
|
|
65
|
+
if (pv < dv) return 'lost';
|
|
66
|
+
return 'draw';
|
|
67
|
+
}
|
package/src/game-flow.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Adapter, Message, Plugin } from 'zhin.js';
|
|
2
|
+
import { recordGameOutcome } from '@zhin.js/game-shared';
|
|
3
|
+
import {
|
|
4
|
+
BJ_PREFIX,
|
|
5
|
+
compareHands,
|
|
6
|
+
dealerShouldHit,
|
|
7
|
+
handValue,
|
|
8
|
+
TARGET,
|
|
9
|
+
} from './engine.js';
|
|
10
|
+
import type { BjSessionRow } from './models.js';
|
|
11
|
+
import type { SessionService } from './session-service.js';
|
|
12
|
+
import { parseCards, parseDeck } from './session-service.js';
|
|
13
|
+
import { buildBjView, naturalOutcome, playerBust } from './view.js';
|
|
14
|
+
|
|
15
|
+
async function sendOrEditView(
|
|
16
|
+
plugin: Plugin,
|
|
17
|
+
services: SessionService,
|
|
18
|
+
message: Message<any>,
|
|
19
|
+
session: BjSessionRow,
|
|
20
|
+
terminal = false,
|
|
21
|
+
revealDealer = false,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const content = buildBjView(session, terminal, revealDealer, message.$channel.type);
|
|
24
|
+
const adapter = plugin.root.inject(message.$adapter) as Adapter;
|
|
25
|
+
|
|
26
|
+
if (session.board_message_id) {
|
|
27
|
+
const msgId = await adapter.editMessage({
|
|
28
|
+
messageId: session.board_message_id,
|
|
29
|
+
context: String(message.$adapter),
|
|
30
|
+
endpoint: message.$endpoint,
|
|
31
|
+
id: message.$channel.id,
|
|
32
|
+
type: message.$channel.type,
|
|
33
|
+
content,
|
|
34
|
+
});
|
|
35
|
+
if (msgId !== session.board_message_id) {
|
|
36
|
+
await services.updateSession(session.id, { board_message_id: msgId });
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const msgId = await message.$reply?.(content);
|
|
42
|
+
if (msgId) await services.updateSession(session.id, { board_message_id: msgId });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function finishRound(
|
|
46
|
+
plugin: Plugin,
|
|
47
|
+
services: SessionService,
|
|
48
|
+
message: Message<any>,
|
|
49
|
+
session: BjSessionRow,
|
|
50
|
+
status: BjSessionRow['status'],
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
await services.updateSession(session.id, { status });
|
|
53
|
+
const updated = (await services.getById(session.id))!;
|
|
54
|
+
if (status === 'won') void recordGameOutcome(message, 'blackjack', 'won', 30);
|
|
55
|
+
else if (status === 'lost') void recordGameOutcome(message, 'blackjack', 'lost');
|
|
56
|
+
else if (status === 'draw') void recordGameOutcome(message, 'blackjack', 'draw');
|
|
57
|
+
await sendOrEditView(plugin, services, message, updated, true, true);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function dealerPlay(
|
|
61
|
+
deck: string[],
|
|
62
|
+
dealer: string[],
|
|
63
|
+
): Promise<string[]> {
|
|
64
|
+
const hand = [...dealer];
|
|
65
|
+
while (dealerShouldHit(hand)) {
|
|
66
|
+
const card = deck.pop();
|
|
67
|
+
if (!card) break;
|
|
68
|
+
hand.push(card);
|
|
69
|
+
}
|
|
70
|
+
return hand;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function startGame(
|
|
74
|
+
plugin: Plugin,
|
|
75
|
+
services: SessionService,
|
|
76
|
+
message: Message<any>,
|
|
77
|
+
): Promise<string | undefined> {
|
|
78
|
+
const ch = `${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`;
|
|
79
|
+
const active = await services.getActiveByChannel(ch);
|
|
80
|
+
if (active) {
|
|
81
|
+
if (active.player_id === message.$sender.id) {
|
|
82
|
+
return '你已有进行中的 21 点,发送「21点 继续」刷新。';
|
|
83
|
+
}
|
|
84
|
+
return `本频道 ${active.player_name} 正在玩 21 点。`;
|
|
85
|
+
}
|
|
86
|
+
const session = await services.createSession(message);
|
|
87
|
+
const natural = naturalOutcome(session);
|
|
88
|
+
if (natural) {
|
|
89
|
+
await finishRound(plugin, services, message, session, natural);
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
await sendOrEditView(plugin, services, message, session);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function continueGame(
|
|
97
|
+
plugin: Plugin,
|
|
98
|
+
services: SessionService,
|
|
99
|
+
message: Message<any>,
|
|
100
|
+
): Promise<string> {
|
|
101
|
+
const session = await services.getActiveForUser(
|
|
102
|
+
`${message.$adapter}-${message.$endpoint}-${message.$channel.type}:${message.$channel.id}`,
|
|
103
|
+
message.$sender.id,
|
|
104
|
+
);
|
|
105
|
+
if (!session) return '你没有进行中的 21 点,发送「21点 开始」。';
|
|
106
|
+
await sendOrEditView(plugin, services, message, session);
|
|
107
|
+
return '已刷新 21 点界面。';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function handleChoice(
|
|
111
|
+
plugin: Plugin,
|
|
112
|
+
services: SessionService,
|
|
113
|
+
message: Message<any>,
|
|
114
|
+
sessionId: string,
|
|
115
|
+
choiceId: string,
|
|
116
|
+
): Promise<string | null> {
|
|
117
|
+
const session = await services.getById(sessionId);
|
|
118
|
+
if (!session) return '对局不存在。';
|
|
119
|
+
if (session.player_id !== message.$sender.id) return '这是别人的对局。';
|
|
120
|
+
|
|
121
|
+
if (session.status !== 'active' && choiceId === 'restart') {
|
|
122
|
+
await services.updateSession(session.id, { status: 'aborted' });
|
|
123
|
+
await startGame(plugin, services, message);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (session.status !== 'active') return '对局已结束,请点击再来一局。';
|
|
128
|
+
|
|
129
|
+
let deck = parseDeck(session.deck_json);
|
|
130
|
+
let player = parseCards(session.player_cards_json);
|
|
131
|
+
let dealer = parseCards(session.dealer_cards_json);
|
|
132
|
+
|
|
133
|
+
if (choiceId === 'hit') {
|
|
134
|
+
const card = deck.pop();
|
|
135
|
+
if (!card) return '牌堆已空。';
|
|
136
|
+
player = [...player, card];
|
|
137
|
+
await services.updateSession(session.id, {
|
|
138
|
+
deck_json: JSON.stringify(deck),
|
|
139
|
+
player_cards_json: JSON.stringify(player),
|
|
140
|
+
});
|
|
141
|
+
if (playerBust({ ...session, player_cards_json: JSON.stringify(player) })) {
|
|
142
|
+
await finishRound(plugin, services, message, session, 'lost');
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const updated = (await services.getById(session.id))!;
|
|
146
|
+
await sendOrEditView(plugin, services, message, updated);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (choiceId !== 'stand') return '无效操作。';
|
|
151
|
+
|
|
152
|
+
dealer = await dealerPlay(deck, dealer);
|
|
153
|
+
await services.updateSession(session.id, {
|
|
154
|
+
deck_json: JSON.stringify(deck),
|
|
155
|
+
dealer_cards_json: JSON.stringify(dealer),
|
|
156
|
+
});
|
|
157
|
+
const outcome = compareHands(player, dealer);
|
|
158
|
+
const status: BjSessionRow['status'] =
|
|
159
|
+
outcome === 'won' ? 'won' : outcome === 'lost' ? 'lost' : 'draw';
|
|
160
|
+
await finishRound(plugin, services, message, session, status);
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export { BJ_PREFIX, handValue, TARGET };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getPlugin } from 'zhin.js';
|
|
2
|
+
import { ensureGameHubService } from '@zhin.js/game-shared';
|
|
3
|
+
import { runBjCommand, BJ_HELP } from './bj-command.js';
|
|
4
|
+
import type { SessionService } from './session-service.js';
|
|
5
|
+
|
|
6
|
+
export function registerBjHub(getServices: () => SessionService | null): () => void {
|
|
7
|
+
const plugin = getPlugin();
|
|
8
|
+
ensureGameHubService(plugin);
|
|
9
|
+
return plugin.registerGame({
|
|
10
|
+
id: 'blackjack',
|
|
11
|
+
title: '21 点',
|
|
12
|
+
icon: '🃏',
|
|
13
|
+
description: '经典 Blackjack,要牌或停牌挑战庄家',
|
|
14
|
+
commandPrefix: '/21点',
|
|
15
|
+
quickStart: '开始',
|
|
16
|
+
aliases: ['bj', '21点'],
|
|
17
|
+
menus: [
|
|
18
|
+
{ id: 'start', label: '🎮 开始新局', style: 'primary' },
|
|
19
|
+
{ id: 'help', label: '📖 玩法说明' },
|
|
20
|
+
],
|
|
21
|
+
runAction: async (actionId, ctx) => {
|
|
22
|
+
const services = getServices();
|
|
23
|
+
if (!services) return '21 点需要启用 database 配置。';
|
|
24
|
+
if (actionId === 'help') return BJ_HELP;
|
|
25
|
+
return runBjCommand(plugin, services, ctx.message, actionId === 'start' ? '开始' : actionId);
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|