maid-poker-cli 1.0.0 → 1.0.1
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 +81 -5
- package/dist/client.js +72 -76
- package/dist/game/__tests__/logic.test.js +467 -0
- package/dist/game/logic.js +217 -60
- package/dist/game/poker.js +19 -0
- package/dist/index.js +33 -2
- package/dist/server.js +69 -61
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -4,9 +4,12 @@ A command-line multiplayer Dou Dizhu (斗地主) game built with Node.js and Soc
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
- **Multiplayer**: Host and join games over local network.
|
|
7
|
+
- **Two Game Modes**:
|
|
8
|
+
- 三人斗地主 (1副牌, 标准规则)
|
|
9
|
+
- 四人斗地主 (2副牌, 扩展规则)
|
|
7
10
|
- **Beautiful UI**: ASCII art for cards, special effects for Bombs, Planes, and Rockets.
|
|
8
11
|
- **Interactive**: Easy-to-use CLI interface.
|
|
9
|
-
- **Rules**:
|
|
12
|
+
- **Rules**: Complete Dou Dizhu rules with robust card validation.
|
|
10
13
|
|
|
11
14
|
## Installation
|
|
12
15
|
|
|
@@ -22,14 +25,87 @@ maid-poker
|
|
|
22
25
|
```
|
|
23
26
|
|
|
24
27
|
### Hosting
|
|
25
|
-
1. Select "Host Game".
|
|
26
|
-
2.
|
|
27
|
-
3.
|
|
28
|
+
1. Select "创建游戏 (Host Game)".
|
|
29
|
+
2. Choose game mode (三人/四人斗地主).
|
|
30
|
+
3. Share your IP address with friends.
|
|
31
|
+
4. Wait for players to join.
|
|
28
32
|
|
|
29
33
|
### Joining
|
|
30
|
-
1. Select "Join Game".
|
|
34
|
+
1. Select "加入游戏 (Join Game)".
|
|
31
35
|
2. Enter the Host's IP address.
|
|
32
36
|
3. Enjoy!
|
|
33
37
|
|
|
38
|
+
## Game Modes
|
|
39
|
+
|
|
40
|
+
### 三人斗地主 (Three Player Mode)
|
|
41
|
+
- **牌组**: 1副牌 (54张)
|
|
42
|
+
- **发牌**: 每人17张, 3张底牌
|
|
43
|
+
- **玩家**: 3人 (1地主 vs 2农民)
|
|
44
|
+
|
|
45
|
+
#### 牌型
|
|
46
|
+
| 牌型 | 说明 | 示例 |
|
|
47
|
+
|------|------|------|
|
|
48
|
+
| 单张 | 单张牌 | 7 |
|
|
49
|
+
| 对子 | 两张相同 | QQ |
|
|
50
|
+
| 三张 | 三张相同 | 888 |
|
|
51
|
+
| 三带一 | 三张 + 1张单牌 | 888+6 |
|
|
52
|
+
| 三带对 | 三张 + 1对 | 888+66 |
|
|
53
|
+
| 顺子 | 5张及以上连牌 (不含2和王) | 34567 |
|
|
54
|
+
| 连对 | 3对及以上连续对子 | 778899 |
|
|
55
|
+
| 飞机 | 2组及以上连续三张 | 333444 |
|
|
56
|
+
| 炸弹 | 4张相同 | 6666 |
|
|
57
|
+
| 王炸 | 大王 + 小王 (最强) | 🃏🃏 |
|
|
58
|
+
|
|
59
|
+
### 四人斗地主 (Four Player Mode)
|
|
60
|
+
- **牌组**: 2副牌 (108张)
|
|
61
|
+
- **发牌**: 每人25张, 8张底牌
|
|
62
|
+
- **玩家**: 4人 (1地主 vs 3农民)
|
|
63
|
+
|
|
64
|
+
#### 新增/修改牌型
|
|
65
|
+
| 牌型 | 说明 | 示例 |
|
|
66
|
+
|------|------|------|
|
|
67
|
+
| 顺子 | 5张及以上连牌,**可含2**,不含王 | 34567, JQKA2 |
|
|
68
|
+
| 四带二 | 4张相同 + 2张单牌 | 9999+6+8 |
|
|
69
|
+
| 四带两对 | 4张相同 + 2对 | 9999+55+77 |
|
|
70
|
+
| 王炸 | 大王 + 小王 | 🃏🃏 |
|
|
71
|
+
| 八炸 | 8张相同牌 (2副牌独有) | 88888888 |
|
|
72
|
+
| 天王炸 | 4张王 (最强) | 🃏🃏🃏🃏 |
|
|
73
|
+
|
|
74
|
+
#### 炸弹大小顺序 (四人模式)
|
|
75
|
+
```
|
|
76
|
+
天王炸 (4王) > 八炸 (8张相同) > 王炸 (1大1小) > 普通炸弹 (4张相同)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
同级别炸弹比较点数大小 (如 2222 > AAAA > KKKK...)
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Install dependencies
|
|
85
|
+
npm install
|
|
86
|
+
|
|
87
|
+
# Run in development mode
|
|
88
|
+
npm start
|
|
89
|
+
|
|
90
|
+
# Run tests
|
|
91
|
+
npm test
|
|
92
|
+
|
|
93
|
+
# Build for production
|
|
94
|
+
npm run build
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Testing
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# Run all tests
|
|
101
|
+
npm test
|
|
102
|
+
|
|
103
|
+
# Run tests in watch mode
|
|
104
|
+
npm run test:watch
|
|
105
|
+
|
|
106
|
+
# Run tests with coverage
|
|
107
|
+
npm run test:coverage
|
|
108
|
+
```
|
|
109
|
+
|
|
34
110
|
## License
|
|
35
111
|
ISC
|
package/dist/client.js
CHANGED
|
@@ -23,6 +23,7 @@ class GameClient {
|
|
|
23
23
|
constructor(serverUrl, name) {
|
|
24
24
|
this.hand = [];
|
|
25
25
|
this.myTurn = false;
|
|
26
|
+
this.gameMode = 'three_player';
|
|
26
27
|
this.name = name;
|
|
27
28
|
this.socket = (0, socket_io_client_1.io)(serverUrl);
|
|
28
29
|
this.setupEvents();
|
|
@@ -32,15 +33,23 @@ class GameClient {
|
|
|
32
33
|
console.log(chalk_1.default.green('已连接到服务器!'));
|
|
33
34
|
this.socket.emit('join_game', this.name);
|
|
34
35
|
});
|
|
35
|
-
this.socket.on('
|
|
36
|
-
|
|
36
|
+
this.socket.on('game_mode', (data) => {
|
|
37
|
+
this.gameMode = data.mode;
|
|
38
|
+
const modeText = data.mode === 'three_player' ? '三人斗地主' : '四人斗地主(2副牌)';
|
|
39
|
+
console.log(chalk_1.default.magenta(`\n游戏模式: ${modeText} (需要 ${data.requiredPlayers} 位玩家)`));
|
|
40
|
+
});
|
|
41
|
+
this.socket.on('player_list_update', (data) => {
|
|
42
|
+
console.log(chalk_1.default.cyan(`\n当前玩家 (${data.names.length}/${data.required}): ${data.names.join(', ')}`));
|
|
37
43
|
});
|
|
38
44
|
this.socket.on('error_msg', (msg) => {
|
|
39
45
|
console.log(chalk_1.default.red('错误:', msg));
|
|
40
46
|
});
|
|
41
47
|
this.socket.on('game_start', (data) => {
|
|
42
48
|
console.clear();
|
|
49
|
+
this.gameMode = data.gameMode;
|
|
50
|
+
const modeText = data.gameMode === 'three_player' ? '三人斗地主' : '四人斗地主';
|
|
43
51
|
console.log(chalk_1.default.green(figlet_1.default.textSync('Game Start!', { font: 'Standard' })));
|
|
52
|
+
console.log(chalk_1.default.magenta(`模式: ${modeText}`));
|
|
44
53
|
this.hand = data.hand;
|
|
45
54
|
this.printHand();
|
|
46
55
|
console.log(chalk_1.default.yellow('等待叫分...'));
|
|
@@ -50,8 +59,9 @@ class GameClient {
|
|
|
50
59
|
this.printHand();
|
|
51
60
|
});
|
|
52
61
|
this.socket.on('landlord_chosen', (data) => {
|
|
62
|
+
const cardCount = data.gameMode === 'three_player' ? 3 : 8;
|
|
53
63
|
console.log(chalk_1.default.yellow(`\n地主是 ${data.landlordName}!`));
|
|
54
|
-
console.log(
|
|
64
|
+
console.log(`底牌 (${cardCount}张):`);
|
|
55
65
|
console.log((0, view_1.renderCards)(data.hiddenCards));
|
|
56
66
|
});
|
|
57
67
|
this.socket.on('turn_update', (data) => {
|
|
@@ -61,64 +71,14 @@ class GameClient {
|
|
|
61
71
|
}
|
|
62
72
|
});
|
|
63
73
|
this.socket.on('player_played', (data) => {
|
|
64
|
-
// Clear screen to make it cleaner?
|
|
65
|
-
// User requested: "出牌后清理上一轮命令行内容是否体验更好" -> Yes, but we need to redraw state.
|
|
66
|
-
// But if we clear, we lose history of what happened.
|
|
67
|
-
// Let's at least clear for the USER's OWN turn before input, or here if we want a fresh table.
|
|
68
|
-
// Let's try clearing console before showing the played cards to focus attention.
|
|
69
74
|
console.clear();
|
|
70
|
-
// Check for special effects
|
|
71
75
|
const { analyzeHand } = require('./game/logic');
|
|
72
|
-
const analysis = analyzeHand(data.cards);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
console.log(chalk_1.default.red.bold(figlet_1.default.textSync('ROCKET !!!', { font: 'Doom' })));
|
|
76
|
-
console.log(chalk_1.default.bgRed.white.bold.italic(' 🚀 王 炸 🚀 '));
|
|
77
|
-
}
|
|
78
|
-
else if (analysis.type === 'bomb') {
|
|
79
|
-
// Determine bomb intensity based on rank (3-15)
|
|
80
|
-
const rank = analysis.rankValue;
|
|
81
|
-
let bombText = 'BOMB';
|
|
82
|
-
let fontToUse = 'Standard';
|
|
83
|
-
if (rank <= 7) {
|
|
84
|
-
// Small bomb (3-7)
|
|
85
|
-
fontToUse = 'Small'; // Assuming 'Small' font exists or fallback
|
|
86
|
-
}
|
|
87
|
-
else if (rank <= 13) {
|
|
88
|
-
// Medium bomb (8-K)
|
|
89
|
-
fontToUse = 'Standard';
|
|
90
|
-
}
|
|
91
|
-
else {
|
|
92
|
-
// Big bomb (A-2)
|
|
93
|
-
fontToUse = 'Big';
|
|
94
|
-
}
|
|
95
|
-
// Use Standard/Big since 'Small' might be too small or not exist in standard figlet import
|
|
96
|
-
// Let's stick to Standard vs Big vs Doom for simplicity
|
|
97
|
-
if (rank > 13)
|
|
98
|
-
fontToUse = 'Big';
|
|
99
|
-
else
|
|
100
|
-
fontToUse = 'Standard';
|
|
101
|
-
console.log(chalk_1.default.red.bold(figlet_1.default.textSync(bombText, { font: fontToUse })));
|
|
102
|
-
// Add magnitude indicator
|
|
103
|
-
// Add magnitude indicator
|
|
104
|
-
const stars = '⭐'.repeat(Math.min(5, Math.max(1, rank - 2)));
|
|
105
|
-
const r = rank === 11 ? 'J' : rank === 12 ? 'Q' : rank === 13 ? 'K' : rank === 14 ? 'A' : rank === 15 ? '2' : rank.toString();
|
|
106
|
-
console.log(chalk_1.default.bgRed.white.bold(` 💣 炸 弹 (${r}) 💣 `));
|
|
107
|
-
}
|
|
108
|
-
else if (analysis.type === 'airplane') {
|
|
109
|
-
// Airplane effect
|
|
110
|
-
console.log(chalk_1.default.cyan.bold(figlet_1.default.textSync('PLANE', { font: 'Standard' })));
|
|
111
|
-
console.log(chalk_1.default.bgBlue.white.bold(' ✈️ 飞 机 ✈️ '));
|
|
112
|
-
}
|
|
113
|
-
else if (analysis.type === 'straight') {
|
|
114
|
-
// Straight effect
|
|
115
|
-
console.log(chalk_1.default.green.bold(figlet_1.default.textSync('STRAIGHT', { font: 'Standard' })));
|
|
116
|
-
console.log(chalk_1.default.bgGreen.white.bold(' 🌊 顺 子 🌊 '));
|
|
117
|
-
}
|
|
76
|
+
const analysis = analyzeHand(data.cards, data.gameMode);
|
|
77
|
+
// 特效显示
|
|
78
|
+
this.showHandEffect(analysis, data.gameMode);
|
|
118
79
|
console.log(chalk_1.default.bold(`\n${data.playerName} 打出了:`));
|
|
119
80
|
console.log((0, view_1.renderCards)(data.cards));
|
|
120
81
|
console.log(chalk_1.default.gray(`剩余 ${data.remainingCount} 张牌.`));
|
|
121
|
-
// Re-display my hand so context is not lost after clear
|
|
122
82
|
this.printHand();
|
|
123
83
|
});
|
|
124
84
|
this.socket.on('player_passed', (data) => {
|
|
@@ -129,10 +89,8 @@ class GameClient {
|
|
|
129
89
|
});
|
|
130
90
|
this.socket.on('game_over', (data) => __awaiter(this, void 0, void 0, function* () {
|
|
131
91
|
console.log(chalk_1.default.magenta(figlet_1.default.textSync('Game Over')));
|
|
132
|
-
// data.role is 'landlord' or 'peasant'
|
|
133
92
|
const roleText = data.role === 'landlord' ? '地主' : '农民';
|
|
134
93
|
console.log(chalk_1.default.yellow(`赢家: ${data.winner} (${roleText} 胜利!)`));
|
|
135
|
-
// Replay Prompt
|
|
136
94
|
const { action } = yield inquirer_1.default.prompt([{
|
|
137
95
|
type: 'list',
|
|
138
96
|
name: 'action',
|
|
@@ -150,9 +108,62 @@ class GameClient {
|
|
|
150
108
|
}));
|
|
151
109
|
this.socket.on('your_turn', (data) => __awaiter(this, void 0, void 0, function* () {
|
|
152
110
|
this.myTurn = true;
|
|
111
|
+
this.gameMode = data.gameMode;
|
|
153
112
|
setTimeout(() => this.handleTurn(data.action), 500);
|
|
154
113
|
}));
|
|
155
114
|
}
|
|
115
|
+
showHandEffect(analysis, gameMode) {
|
|
116
|
+
const rank = analysis.rankValue;
|
|
117
|
+
const r = rank === 11 ? 'J' : rank === 12 ? 'Q' : rank === 13 ? 'K' : rank === 14 ? 'A' : rank === 15 ? '2' : rank.toString();
|
|
118
|
+
switch (analysis.type) {
|
|
119
|
+
case 'quad_joker':
|
|
120
|
+
// 天王炸 (4王)
|
|
121
|
+
console.log(chalk_1.default.red.bold(figlet_1.default.textSync('QUAD JOKER', { font: 'Doom' })));
|
|
122
|
+
console.log(chalk_1.default.bgRed.white.bold.italic(' 👑👑 天 王 炸 👑👑 '));
|
|
123
|
+
console.log(chalk_1.default.yellow(' 💥💥💥💥 '));
|
|
124
|
+
break;
|
|
125
|
+
case 'octo_bomb':
|
|
126
|
+
// 八炸 (8张相同)
|
|
127
|
+
console.log(chalk_1.default.red.bold(figlet_1.default.textSync('OCTO BOMB', { font: 'Doom' })));
|
|
128
|
+
console.log(chalk_1.default.bgRed.white.bold.italic(` 🔥🔥 八 炸 (${r}) 🔥🔥 `));
|
|
129
|
+
console.log(chalk_1.default.yellow(' 💣💣💣💣💣💣💣💣 '));
|
|
130
|
+
break;
|
|
131
|
+
case 'rocket':
|
|
132
|
+
// 王炸 (1大1小) - 三人和四人模式通用
|
|
133
|
+
console.log(chalk_1.default.red.bold(figlet_1.default.textSync('ROCKET !!!', { font: 'Doom' })));
|
|
134
|
+
console.log(chalk_1.default.bgRed.white.bold.italic(' 🚀 王 炸 🚀 '));
|
|
135
|
+
break;
|
|
136
|
+
case 'bomb':
|
|
137
|
+
// 普通炸弹
|
|
138
|
+
let fontToUse = 'Standard';
|
|
139
|
+
if (rank > 13)
|
|
140
|
+
fontToUse = 'Big';
|
|
141
|
+
console.log(chalk_1.default.red.bold(figlet_1.default.textSync('BOMB', { font: fontToUse })));
|
|
142
|
+
const stars = '⭐'.repeat(Math.min(5, Math.max(1, rank - 2)));
|
|
143
|
+
console.log(chalk_1.default.bgRed.white.bold(` 💣 炸 弹 (${r}) 💣 `));
|
|
144
|
+
break;
|
|
145
|
+
case 'airplane':
|
|
146
|
+
console.log(chalk_1.default.cyan.bold(figlet_1.default.textSync('PLANE', { font: 'Standard' })));
|
|
147
|
+
console.log(chalk_1.default.bgBlue.white.bold(' ✈️ 飞 机 ✈️ '));
|
|
148
|
+
break;
|
|
149
|
+
case 'straight':
|
|
150
|
+
console.log(chalk_1.default.green.bold(figlet_1.default.textSync('STRAIGHT', { font: 'Standard' })));
|
|
151
|
+
console.log(chalk_1.default.bgGreen.white.bold(' 🌊 顺 子 🌊 '));
|
|
152
|
+
break;
|
|
153
|
+
case 'consecutive_pairs':
|
|
154
|
+
console.log(chalk_1.default.blue.bold(figlet_1.default.textSync('PAIRS', { font: 'Standard' })));
|
|
155
|
+
console.log(chalk_1.default.bgBlue.white.bold(' 🔗 连 对 🔗 '));
|
|
156
|
+
break;
|
|
157
|
+
case 'four_with_two':
|
|
158
|
+
console.log(chalk_1.default.yellow.bold(figlet_1.default.textSync('4+2', { font: 'Standard' })));
|
|
159
|
+
console.log(chalk_1.default.bgYellow.black.bold(` 💪 四带二 (${r}) 💪 `));
|
|
160
|
+
break;
|
|
161
|
+
case 'four_with_pairs':
|
|
162
|
+
console.log(chalk_1.default.yellow.bold(figlet_1.default.textSync('4+2x2', { font: 'Standard' })));
|
|
163
|
+
console.log(chalk_1.default.bgYellow.black.bold(` 💪 四带两对 (${r}) 💪 `));
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
156
167
|
colorCard(c) {
|
|
157
168
|
const str = (0, poker_1.formatCard)(c);
|
|
158
169
|
if (c.suit === '♥' || c.suit === '♦' || c.rank === 'RJ') {
|
|
@@ -163,7 +174,8 @@ class GameClient {
|
|
|
163
174
|
}
|
|
164
175
|
}
|
|
165
176
|
printHand() {
|
|
166
|
-
|
|
177
|
+
const handCount = this.hand.length;
|
|
178
|
+
console.log(chalk_1.default.blue(`\n你的手牌 (${handCount}张):`));
|
|
167
179
|
console.log((0, view_1.renderCards)(this.hand));
|
|
168
180
|
console.log('\n');
|
|
169
181
|
}
|
|
@@ -179,7 +191,6 @@ class GameClient {
|
|
|
179
191
|
this.socket.emit('bid_landlord', bid ? 1 : 0);
|
|
180
192
|
}
|
|
181
193
|
else {
|
|
182
|
-
// Updated to use input for cards
|
|
183
194
|
const { cardInput } = yield inquirer_1.default.prompt([{
|
|
184
195
|
type: 'input',
|
|
185
196
|
name: 'cardInput',
|
|
@@ -212,25 +223,17 @@ class GameClient {
|
|
|
212
223
|
this.myTurn = false;
|
|
213
224
|
});
|
|
214
225
|
}
|
|
215
|
-
// Helper to match input string to hand cards
|
|
216
|
-
// Supports "34567", "3344", "3 4 5", "10 J Q K"
|
|
217
226
|
parseCards(input) {
|
|
218
|
-
const tempHand = [...this.hand];
|
|
227
|
+
const tempHand = [...this.hand];
|
|
219
228
|
const selected = [];
|
|
220
229
|
let tokens = [];
|
|
221
|
-
// Revised parsing logic:
|
|
222
|
-
// Parse left-to-right, looking for valid tokens.
|
|
223
|
-
// Priority: Multi-char tokens (10, BJ, RJ) > Single-char tokens.
|
|
224
|
-
// Aliases: 0 -> 10, T -> 10.
|
|
225
230
|
let remaining = input.toUpperCase();
|
|
226
231
|
while (remaining.length > 0) {
|
|
227
|
-
// Skip separators
|
|
228
232
|
if (/^[\s,]+/.test(remaining)) {
|
|
229
233
|
remaining = remaining.replace(/^[\s,]+/, '');
|
|
230
234
|
continue;
|
|
231
235
|
}
|
|
232
236
|
let foundToken = '';
|
|
233
|
-
// 1. Check for specific multi-char tokens aliases first
|
|
234
237
|
if (remaining.startsWith('10')) {
|
|
235
238
|
foundToken = '10';
|
|
236
239
|
remaining = remaining.substring(2);
|
|
@@ -243,7 +246,6 @@ class GameClient {
|
|
|
243
246
|
foundToken = 'RJ';
|
|
244
247
|
remaining = remaining.substring(2);
|
|
245
248
|
}
|
|
246
|
-
// 2. Check for Single char aliases for 10
|
|
247
249
|
else if (remaining.startsWith('0')) {
|
|
248
250
|
foundToken = '10';
|
|
249
251
|
remaining = remaining.substring(1);
|
|
@@ -252,7 +254,6 @@ class GameClient {
|
|
|
252
254
|
foundToken = '10';
|
|
253
255
|
remaining = remaining.substring(1);
|
|
254
256
|
}
|
|
255
|
-
// 3. Check for standard single chars
|
|
256
257
|
else {
|
|
257
258
|
const char = remaining[0];
|
|
258
259
|
if (poker_1.RANKS.includes(char)) {
|
|
@@ -260,7 +261,6 @@ class GameClient {
|
|
|
260
261
|
remaining = remaining.substring(1);
|
|
261
262
|
}
|
|
262
263
|
else {
|
|
263
|
-
// Invalid character found
|
|
264
264
|
return null;
|
|
265
265
|
}
|
|
266
266
|
}
|
|
@@ -268,15 +268,11 @@ class GameClient {
|
|
|
268
268
|
tokens.push(foundToken);
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
|
-
// Now maps tokens to cards in hand
|
|
272
271
|
for (const token of tokens) {
|
|
273
|
-
// Token is already normalized rank string (e.g. '10', 'A', 'BJ')
|
|
274
272
|
const idx = tempHand.findIndex(c => c.rank === token);
|
|
275
273
|
if (idx === -1) {
|
|
276
|
-
// Not found in hand
|
|
277
274
|
return null;
|
|
278
275
|
}
|
|
279
|
-
// Remove from tempHand to avoid double counting same card
|
|
280
276
|
selected.push(tempHand[idx]);
|
|
281
277
|
tempHand.splice(idx, 1);
|
|
282
278
|
}
|