maid-poker-cli 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.
- package/README.md +35 -0
- package/dist/client.js +286 -0
- package/dist/game/logic.js +137 -0
- package/dist/game/poker.js +51 -0
- package/dist/game/view.js +60 -0
- package/dist/index.js +86 -0
- package/dist/server.js +240 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Maid Poker (女仆扑克牌)
|
|
2
|
+
|
|
3
|
+
A command-line multiplayer Dou Dizhu (斗地主) game built with Node.js and Socket.io.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
- **Multiplayer**: Host and join games over local network.
|
|
7
|
+
- **Beautiful UI**: ASCII art for cards, special effects for Bombs, Planes, and Rockets.
|
|
8
|
+
- **Interactive**: Easy-to-use CLI interface.
|
|
9
|
+
- **Rules**: Standard Dou Dizhu rules with robust card validation.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g maid-poker-cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
Start the game:
|
|
20
|
+
```bash
|
|
21
|
+
maid-poker
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Hosting
|
|
25
|
+
1. Select "Host Game".
|
|
26
|
+
2. Share your IP address with friends.
|
|
27
|
+
3. Wait for players to join.
|
|
28
|
+
|
|
29
|
+
### Joining
|
|
30
|
+
1. Select "Join Game".
|
|
31
|
+
2. Enter the Host's IP address.
|
|
32
|
+
3. Enjoy!
|
|
33
|
+
|
|
34
|
+
## License
|
|
35
|
+
ISC
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.GameClient = void 0;
|
|
16
|
+
const socket_io_client_1 = require("socket.io-client");
|
|
17
|
+
const inquirer_1 = __importDefault(require("inquirer"));
|
|
18
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
19
|
+
const figlet_1 = __importDefault(require("figlet"));
|
|
20
|
+
const poker_1 = require("./game/poker");
|
|
21
|
+
const view_1 = require("./game/view");
|
|
22
|
+
class GameClient {
|
|
23
|
+
constructor(serverUrl, name) {
|
|
24
|
+
this.hand = [];
|
|
25
|
+
this.myTurn = false;
|
|
26
|
+
this.name = name;
|
|
27
|
+
this.socket = (0, socket_io_client_1.io)(serverUrl);
|
|
28
|
+
this.setupEvents();
|
|
29
|
+
}
|
|
30
|
+
setupEvents() {
|
|
31
|
+
this.socket.on('connect', () => {
|
|
32
|
+
console.log(chalk_1.default.green('已连接到服务器!'));
|
|
33
|
+
this.socket.emit('join_game', this.name);
|
|
34
|
+
});
|
|
35
|
+
this.socket.on('player_list_update', (names) => {
|
|
36
|
+
console.log(chalk_1.default.cyan(`\n当前玩家: ${names.join(', ')}`));
|
|
37
|
+
});
|
|
38
|
+
this.socket.on('error_msg', (msg) => {
|
|
39
|
+
console.log(chalk_1.default.red('错误:', msg));
|
|
40
|
+
});
|
|
41
|
+
this.socket.on('game_start', (data) => {
|
|
42
|
+
console.clear();
|
|
43
|
+
console.log(chalk_1.default.green(figlet_1.default.textSync('Game Start!', { font: 'Standard' })));
|
|
44
|
+
this.hand = data.hand;
|
|
45
|
+
this.printHand();
|
|
46
|
+
console.log(chalk_1.default.yellow('等待叫分...'));
|
|
47
|
+
});
|
|
48
|
+
this.socket.on('hand_update', (newHand) => {
|
|
49
|
+
this.hand = newHand;
|
|
50
|
+
this.printHand();
|
|
51
|
+
});
|
|
52
|
+
this.socket.on('landlord_chosen', (data) => {
|
|
53
|
+
console.log(chalk_1.default.yellow(`\n地主是 ${data.landlordName}!`));
|
|
54
|
+
console.log('底牌:');
|
|
55
|
+
console.log((0, view_1.renderCards)(data.hiddenCards));
|
|
56
|
+
});
|
|
57
|
+
this.socket.on('turn_update', (data) => {
|
|
58
|
+
if (data.playerName !== this.name) {
|
|
59
|
+
const actionText = data.action === 'bid' ? '叫分' : '出牌';
|
|
60
|
+
console.log(chalk_1.default.gray(`\n等待 ${data.playerName} ${actionText}...`));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
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
|
+
console.clear();
|
|
70
|
+
// Check for special effects
|
|
71
|
+
const { analyzeHand } = require('./game/logic');
|
|
72
|
+
const analysis = analyzeHand(data.cards);
|
|
73
|
+
if (analysis.type === 'rocket') {
|
|
74
|
+
// Rocket (King Bomb)
|
|
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
|
+
}
|
|
118
|
+
console.log(chalk_1.default.bold(`\n${data.playerName} 打出了:`));
|
|
119
|
+
console.log((0, view_1.renderCards)(data.cards));
|
|
120
|
+
console.log(chalk_1.default.gray(`剩余 ${data.remainingCount} 张牌.`));
|
|
121
|
+
// Re-display my hand so context is not lost after clear
|
|
122
|
+
this.printHand();
|
|
123
|
+
});
|
|
124
|
+
this.socket.on('player_passed', (data) => {
|
|
125
|
+
console.log(chalk_1.default.gray(`\n${data.playerName} 要不起.`));
|
|
126
|
+
});
|
|
127
|
+
this.socket.on('new_round', (data) => {
|
|
128
|
+
console.log(chalk_1.default.green(`\n${data.playerName} 获得新回合!`));
|
|
129
|
+
});
|
|
130
|
+
this.socket.on('game_over', (data) => __awaiter(this, void 0, void 0, function* () {
|
|
131
|
+
console.log(chalk_1.default.magenta(figlet_1.default.textSync('Game Over')));
|
|
132
|
+
// data.role is 'landlord' or 'peasant'
|
|
133
|
+
const roleText = data.role === 'landlord' ? '地主' : '农民';
|
|
134
|
+
console.log(chalk_1.default.yellow(`赢家: ${data.winner} (${roleText} 胜利!)`));
|
|
135
|
+
// Replay Prompt
|
|
136
|
+
const { action } = yield inquirer_1.default.prompt([{
|
|
137
|
+
type: 'list',
|
|
138
|
+
name: 'action',
|
|
139
|
+
message: '本局结束,你想做什么?',
|
|
140
|
+
choices: ['继续游戏 (Play Again)', '退出 (Exit)']
|
|
141
|
+
}]);
|
|
142
|
+
if (action.includes('Exit')) {
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
console.clear();
|
|
147
|
+
console.log(chalk_1.default.green('等待其他玩家准备...'));
|
|
148
|
+
this.socket.emit('player_ready');
|
|
149
|
+
}
|
|
150
|
+
}));
|
|
151
|
+
this.socket.on('your_turn', (data) => __awaiter(this, void 0, void 0, function* () {
|
|
152
|
+
this.myTurn = true;
|
|
153
|
+
setTimeout(() => this.handleTurn(data.action), 500);
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
colorCard(c) {
|
|
157
|
+
const str = (0, poker_1.formatCard)(c);
|
|
158
|
+
if (c.suit === '♥' || c.suit === '♦' || c.rank === 'RJ') {
|
|
159
|
+
return chalk_1.default.red(str);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
return chalk_1.default.black.bgWhite(str);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
printHand() {
|
|
166
|
+
console.log(chalk_1.default.blue('\n你的手牌:'));
|
|
167
|
+
console.log((0, view_1.renderCards)(this.hand));
|
|
168
|
+
console.log('\n');
|
|
169
|
+
}
|
|
170
|
+
handleTurn(action) {
|
|
171
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
172
|
+
if (action === 'bid') {
|
|
173
|
+
const { bid } = yield inquirer_1.default.prompt([{
|
|
174
|
+
type: 'confirm',
|
|
175
|
+
name: 'bid',
|
|
176
|
+
message: '你想叫地主吗?',
|
|
177
|
+
default: false
|
|
178
|
+
}]);
|
|
179
|
+
this.socket.emit('bid_landlord', bid ? 1 : 0);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
// Updated to use input for cards
|
|
183
|
+
const { cardInput } = yield inquirer_1.default.prompt([{
|
|
184
|
+
type: 'input',
|
|
185
|
+
name: 'cardInput',
|
|
186
|
+
message: '请输入要出的牌 (例如 "34567" 或 "3 4 5", 直接回车表示不出):'
|
|
187
|
+
}]);
|
|
188
|
+
const inputStr = (cardInput || '').trim();
|
|
189
|
+
if (!inputStr) {
|
|
190
|
+
const { confirmPass } = yield inquirer_1.default.prompt([{
|
|
191
|
+
type: 'confirm',
|
|
192
|
+
name: 'confirmPass',
|
|
193
|
+
message: '确认不出(PASS)吗?',
|
|
194
|
+
default: true
|
|
195
|
+
}]);
|
|
196
|
+
if (confirmPass) {
|
|
197
|
+
this.socket.emit('pass');
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
return this.handleTurn('play');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const selectedCards = this.parseCards(inputStr);
|
|
205
|
+
if (!selectedCards) {
|
|
206
|
+
console.log(chalk_1.default.red('无效的牌输入,请检查你的手牌和输入格式 (如 334455, 3 3 4 4)'));
|
|
207
|
+
return this.handleTurn('play');
|
|
208
|
+
}
|
|
209
|
+
this.socket.emit('play_cards', selectedCards);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
this.myTurn = false;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
// Helper to match input string to hand cards
|
|
216
|
+
// Supports "34567", "3344", "3 4 5", "10 J Q K"
|
|
217
|
+
parseCards(input) {
|
|
218
|
+
const tempHand = [...this.hand]; // Copy to track usage
|
|
219
|
+
const selected = [];
|
|
220
|
+
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
|
+
let remaining = input.toUpperCase();
|
|
226
|
+
while (remaining.length > 0) {
|
|
227
|
+
// Skip separators
|
|
228
|
+
if (/^[\s,]+/.test(remaining)) {
|
|
229
|
+
remaining = remaining.replace(/^[\s,]+/, '');
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
let foundToken = '';
|
|
233
|
+
// 1. Check for specific multi-char tokens aliases first
|
|
234
|
+
if (remaining.startsWith('10')) {
|
|
235
|
+
foundToken = '10';
|
|
236
|
+
remaining = remaining.substring(2);
|
|
237
|
+
}
|
|
238
|
+
else if (remaining.startsWith('BJ')) {
|
|
239
|
+
foundToken = 'BJ';
|
|
240
|
+
remaining = remaining.substring(2);
|
|
241
|
+
}
|
|
242
|
+
else if (remaining.startsWith('RJ')) {
|
|
243
|
+
foundToken = 'RJ';
|
|
244
|
+
remaining = remaining.substring(2);
|
|
245
|
+
}
|
|
246
|
+
// 2. Check for Single char aliases for 10
|
|
247
|
+
else if (remaining.startsWith('0')) {
|
|
248
|
+
foundToken = '10';
|
|
249
|
+
remaining = remaining.substring(1);
|
|
250
|
+
}
|
|
251
|
+
else if (remaining.startsWith('T')) {
|
|
252
|
+
foundToken = '10';
|
|
253
|
+
remaining = remaining.substring(1);
|
|
254
|
+
}
|
|
255
|
+
// 3. Check for standard single chars
|
|
256
|
+
else {
|
|
257
|
+
const char = remaining[0];
|
|
258
|
+
if (poker_1.RANKS.includes(char)) {
|
|
259
|
+
foundToken = char;
|
|
260
|
+
remaining = remaining.substring(1);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
// Invalid character found
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (foundToken) {
|
|
268
|
+
tokens.push(foundToken);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Now maps tokens to cards in hand
|
|
272
|
+
for (const token of tokens) {
|
|
273
|
+
// Token is already normalized rank string (e.g. '10', 'A', 'BJ')
|
|
274
|
+
const idx = tempHand.findIndex(c => c.rank === token);
|
|
275
|
+
if (idx === -1) {
|
|
276
|
+
// Not found in hand
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
// Remove from tempHand to avoid double counting same card
|
|
280
|
+
selected.push(tempHand[idx]);
|
|
281
|
+
tempHand.splice(idx, 1);
|
|
282
|
+
}
|
|
283
|
+
return selected;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
exports.GameClient = GameClient;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.analyzeHand = analyzeHand;
|
|
4
|
+
exports.canBeat = canBeat;
|
|
5
|
+
function analyzeHand(cards) {
|
|
6
|
+
const len = cards.length;
|
|
7
|
+
if (len === 0)
|
|
8
|
+
return { type: 'invalid', rankValue: 0, length: 0 };
|
|
9
|
+
// Sort descending
|
|
10
|
+
cards.sort((a, b) => b.value - a.value);
|
|
11
|
+
// Check Rocket (Red Joker + Black Joker)
|
|
12
|
+
if (len === 2 && cards[0].value === 17 && cards[1].value === 16) {
|
|
13
|
+
return { type: 'rocket', rankValue: 100, length: 2 };
|
|
14
|
+
}
|
|
15
|
+
// Check Bomb (4 same rank)
|
|
16
|
+
if (len === 4 && cards[0].value === cards[3].value) {
|
|
17
|
+
return { type: 'bomb', rankValue: cards[0].value, length: 4 };
|
|
18
|
+
}
|
|
19
|
+
// Single
|
|
20
|
+
if (len === 1) {
|
|
21
|
+
return { type: 'single', rankValue: cards[0].value, length: 1 };
|
|
22
|
+
}
|
|
23
|
+
// Pair
|
|
24
|
+
if (len === 2 && cards[0].value === cards[1].value) {
|
|
25
|
+
return { type: 'pair', rankValue: cards[0].value, length: 2 };
|
|
26
|
+
}
|
|
27
|
+
// Triple
|
|
28
|
+
if (len === 3 && cards[0].value === cards[2].value) {
|
|
29
|
+
return { type: 'triple', rankValue: cards[0].value, length: 3 };
|
|
30
|
+
}
|
|
31
|
+
// Triple + One
|
|
32
|
+
if (len === 4) {
|
|
33
|
+
// 3 same + 1 diff. Because sorted, 3 same must be at start or end.
|
|
34
|
+
// AAA B or B AAA
|
|
35
|
+
if (cards[0].value === cards[2].value) { // AAA B
|
|
36
|
+
return { type: 'triple_one', rankValue: cards[0].value, length: 4 };
|
|
37
|
+
}
|
|
38
|
+
if (cards[1].value === cards[3].value) { // B AAA
|
|
39
|
+
return { type: 'triple_one', rankValue: cards[1].value, length: 4 };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Triple + Pair
|
|
43
|
+
if (len === 5) {
|
|
44
|
+
// AAA BB or BB AAA
|
|
45
|
+
if (cards[0].value === cards[2].value && cards[3].value === cards[4].value) { // AAA BB
|
|
46
|
+
return { type: 'triple_pair', rankValue: cards[0].value, length: 5 };
|
|
47
|
+
}
|
|
48
|
+
if (cards[0].value === cards[1].value && cards[2].value === cards[4].value) { // BB AAA
|
|
49
|
+
return { type: 'triple_pair', rankValue: cards[2].value, length: 5 };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Straight (顺子): 5+ cards, consecutive values, max rank A (14). 2 (15) and Jokers (16,17) cannot be in straight.
|
|
53
|
+
if (len >= 5) {
|
|
54
|
+
const isStraight = cards.every((c, i) => {
|
|
55
|
+
if (i === 0)
|
|
56
|
+
return true;
|
|
57
|
+
return c.value === cards[i - 1].value - 1;
|
|
58
|
+
});
|
|
59
|
+
// Check max value is not 2 or Joker
|
|
60
|
+
// cards are sorted desc, so cards[0] is max
|
|
61
|
+
if (isStraight && cards[0].value < 15) {
|
|
62
|
+
return { type: 'straight', rankValue: cards[0].value, length: len };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Consecutive Pairs (连对): 3+ pairs (6+ cards), consecutive pair values. No 2, No Joker.
|
|
66
|
+
if (len >= 6 && len % 2 === 0) {
|
|
67
|
+
let isConsecutivePairs = true;
|
|
68
|
+
for (let i = 0; i < len; i += 2) {
|
|
69
|
+
// Check pair
|
|
70
|
+
if (cards[i].value !== cards[i + 1].value) {
|
|
71
|
+
isConsecutivePairs = false;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
// Check consecutive with previous pair
|
|
75
|
+
if (i > 0) {
|
|
76
|
+
if (cards[i].value !== cards[i - 2].value - 1) {
|
|
77
|
+
isConsecutivePairs = false;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (isConsecutivePairs && cards[0].value < 15) {
|
|
83
|
+
return { type: 'consecutive_pairs', rankValue: cards[0].value, length: len };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Airplane (飞机): Consecutive Triples (e.g., 333444).
|
|
87
|
+
// Simplify: Just check strictly consecutive triples without wings for this detector,
|
|
88
|
+
// or heuristic: if we find consecutive triples, we call it airplane for effect.
|
|
89
|
+
// Let's do a basic frequency map check.
|
|
90
|
+
const counts = {};
|
|
91
|
+
cards.forEach(c => counts[c.value] = (counts[c.value] || 0) + 1);
|
|
92
|
+
const triples = [];
|
|
93
|
+
for (const val in counts) {
|
|
94
|
+
if (counts[val] >= 3)
|
|
95
|
+
triples.push(Number(val));
|
|
96
|
+
}
|
|
97
|
+
triples.sort((a, b) => b - a); // Descending
|
|
98
|
+
// Check for consecutive triples
|
|
99
|
+
let consecutiveTriples = 0;
|
|
100
|
+
for (let i = 0; i < triples.length - 1; i++) {
|
|
101
|
+
if (triples[i] === triples[i + 1] + 1 && triples[i] < 15) { // No 2/Jokers in airplane
|
|
102
|
+
consecutiveTriples++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// minimal airplane is 2 consecutive triples
|
|
106
|
+
if (consecutiveTriples >= 1) {
|
|
107
|
+
// This is a loose check, mostly for the Visual Effect triggering
|
|
108
|
+
// Since we want to show "Airplane" effect.
|
|
109
|
+
return { type: 'airplane', rankValue: triples[0], length: len };
|
|
110
|
+
}
|
|
111
|
+
return { type: 'invalid', rankValue: 0, length: 0 };
|
|
112
|
+
}
|
|
113
|
+
function canBeat(lastHand, newHand) {
|
|
114
|
+
const last = analyzeHand(lastHand);
|
|
115
|
+
const curr = analyzeHand(newHand);
|
|
116
|
+
if (curr.type === 'invalid')
|
|
117
|
+
return false;
|
|
118
|
+
// Rocket beats everything
|
|
119
|
+
if (curr.type === 'rocket')
|
|
120
|
+
return true;
|
|
121
|
+
if (last.type === 'rocket')
|
|
122
|
+
return false;
|
|
123
|
+
// Bomb beats everything except rocket and bigger bomb
|
|
124
|
+
if (curr.type === 'bomb') {
|
|
125
|
+
if (last.type === 'bomb')
|
|
126
|
+
return curr.rankValue > last.rankValue;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
if (last.type === 'bomb')
|
|
130
|
+
return false; // Current is not bomb/rocket
|
|
131
|
+
// Otherwise, types must match and lengths must match (usually)
|
|
132
|
+
if (curr.type !== last.type)
|
|
133
|
+
return false;
|
|
134
|
+
if (curr.length !== last.length)
|
|
135
|
+
return false;
|
|
136
|
+
return curr.rankValue > last.rankValue;
|
|
137
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RANKS = void 0;
|
|
4
|
+
exports.getCardValue = getCardValue;
|
|
5
|
+
exports.createDeck = createDeck;
|
|
6
|
+
exports.shuffleDeck = shuffleDeck;
|
|
7
|
+
exports.sortHand = sortHand;
|
|
8
|
+
exports.formatCard = formatCard;
|
|
9
|
+
exports.RANKS = ['3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A', '2', 'BJ', 'RJ'];
|
|
10
|
+
function getCardValue(rank) {
|
|
11
|
+
const index = exports.RANKS.indexOf(rank);
|
|
12
|
+
// 3 is index 0 -> value 3
|
|
13
|
+
// ...
|
|
14
|
+
return index + 3;
|
|
15
|
+
}
|
|
16
|
+
function createDeck() {
|
|
17
|
+
const suits = ['♠', '♥', '♣', '♦'];
|
|
18
|
+
// Normal cards 3 to 2
|
|
19
|
+
const normalRanks = exports.RANKS.slice(0, 13); // 3 to 2
|
|
20
|
+
let deck = [];
|
|
21
|
+
for (const r of normalRanks) {
|
|
22
|
+
for (const s of suits) {
|
|
23
|
+
deck.push({ suit: s, rank: r, value: getCardValue(r) });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Jokers
|
|
27
|
+
deck.push({ suit: '', rank: 'BJ', value: 16 });
|
|
28
|
+
deck.push({ suit: '', rank: 'RJ', value: 17 });
|
|
29
|
+
return deck;
|
|
30
|
+
}
|
|
31
|
+
function shuffleDeck(deck) {
|
|
32
|
+
for (let i = deck.length - 1; i > 0; i--) {
|
|
33
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
34
|
+
[deck[i], deck[j]] = [deck[j], deck[i]];
|
|
35
|
+
}
|
|
36
|
+
return deck;
|
|
37
|
+
}
|
|
38
|
+
function sortHand(hand) {
|
|
39
|
+
// Sort descending by value
|
|
40
|
+
return hand.sort((a, b) => b.value - a.value);
|
|
41
|
+
}
|
|
42
|
+
function formatCard(c) {
|
|
43
|
+
// Add colors later via chalk in client, here just string
|
|
44
|
+
if (c.rank === 'RJ')
|
|
45
|
+
return ' [ 大王 ] '; // Big Joker
|
|
46
|
+
if (c.rank === 'BJ')
|
|
47
|
+
return ' [ 小王 ] '; // Little Joker
|
|
48
|
+
// Pad rank with space if single digit for alignment
|
|
49
|
+
const r = c.rank === '10' ? '10' : ` ${c.rank}`;
|
|
50
|
+
return ` [${c.suit}${r}] `;
|
|
51
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.renderCards = renderCards;
|
|
7
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
8
|
+
function renderCards(cards) {
|
|
9
|
+
if (cards.length === 0)
|
|
10
|
+
return '';
|
|
11
|
+
let line1 = '';
|
|
12
|
+
let line2 = '';
|
|
13
|
+
let line3 = '';
|
|
14
|
+
let line4 = '';
|
|
15
|
+
const colorMap = (c, str) => {
|
|
16
|
+
if (c.suit === '♥' || c.suit === '♦' || c.rank === 'RJ') {
|
|
17
|
+
return chalk_1.default.red(str);
|
|
18
|
+
}
|
|
19
|
+
else if (c.suit === '♠' || c.suit === '♣' || c.rank === 'BJ') {
|
|
20
|
+
return chalk_1.default.white(str); // Spades/Clubs white on black terminal usually best, or black on white
|
|
21
|
+
}
|
|
22
|
+
return str;
|
|
23
|
+
};
|
|
24
|
+
cards.forEach((card, i) => {
|
|
25
|
+
const isFirst = i === 0;
|
|
26
|
+
// Rank Display (2 chars)
|
|
27
|
+
let rankStr = card.rank;
|
|
28
|
+
if (card.rank === '10')
|
|
29
|
+
rankStr = '10';
|
|
30
|
+
else if (card.rank === 'BJ')
|
|
31
|
+
rankStr = 'BJ';
|
|
32
|
+
else if (card.rank === 'RJ')
|
|
33
|
+
rankStr = 'RJ';
|
|
34
|
+
else
|
|
35
|
+
rankStr = `${card.rank} `; // Pad single digit
|
|
36
|
+
// Suit Display (2 chars)
|
|
37
|
+
let suitStr = card.suit ? `${card.suit} ` : ' ';
|
|
38
|
+
// Apply colors to inner content
|
|
39
|
+
const coloredRank = colorMap(card, rankStr);
|
|
40
|
+
const coloredSuit = colorMap(card, suitStr);
|
|
41
|
+
// Build segments
|
|
42
|
+
// Top: ┌──┐ or ──┐
|
|
43
|
+
// Mid1: │RR│ or RR│
|
|
44
|
+
// Mid2: │SS│ or SS│
|
|
45
|
+
// Bot: └──┘ or ──┘
|
|
46
|
+
if (isFirst) {
|
|
47
|
+
line1 += `┌──┐`;
|
|
48
|
+
line2 += `│${coloredRank}│`;
|
|
49
|
+
line3 += `│${coloredSuit}│`;
|
|
50
|
+
line4 += `└──┘`;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
line1 += `──┐`;
|
|
54
|
+
line2 += `${coloredRank}│`;
|
|
55
|
+
line3 += `${coloredSuit}│`;
|
|
56
|
+
line4 += `──┘`;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return `${line1}\n${line2}\n${line3}\n${line4}`;
|
|
60
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
4
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
5
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
6
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
7
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
8
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
9
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
10
|
+
});
|
|
11
|
+
};
|
|
12
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
13
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
14
|
+
};
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
const inquirer_1 = __importDefault(require("inquirer"));
|
|
17
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
18
|
+
const figlet_1 = __importDefault(require("figlet"));
|
|
19
|
+
const server_1 = require("./server");
|
|
20
|
+
const client_1 = require("./client");
|
|
21
|
+
const os_1 = __importDefault(require("os"));
|
|
22
|
+
console.clear();
|
|
23
|
+
console.log(chalk_1.default.red(figlet_1.default.textSync('Maid Poker', { font: 'Standard' })));
|
|
24
|
+
function getLocalIPs() {
|
|
25
|
+
const nets = os_1.default.networkInterfaces();
|
|
26
|
+
const results = [];
|
|
27
|
+
for (const name of Object.keys(nets)) {
|
|
28
|
+
for (const net of nets[name]) {
|
|
29
|
+
// Skip over non-IPv4 and internal (i.e. 127.0.0.1) addresses
|
|
30
|
+
if (net.family === 'IPv4' && !net.internal) {
|
|
31
|
+
results.push(net.address);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return results.length > 0 ? results : ['127.0.0.1'];
|
|
36
|
+
}
|
|
37
|
+
function main() {
|
|
38
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
39
|
+
const { mode } = yield inquirer_1.default.prompt([
|
|
40
|
+
{
|
|
41
|
+
type: 'list',
|
|
42
|
+
name: 'mode',
|
|
43
|
+
message: '你想创建游戏(Host)还是加入游戏(Join)?',
|
|
44
|
+
choices: ['创建游戏 (Host Game)', '加入游戏 (Join Game)']
|
|
45
|
+
}
|
|
46
|
+
]);
|
|
47
|
+
if (mode.includes('Host')) {
|
|
48
|
+
const ips = getLocalIPs();
|
|
49
|
+
console.log(chalk_1.default.blue(`\n服务器已启动,请尝试以下IP: \n${ips.map(ip => ` - ${ip}`).join('\n')}`));
|
|
50
|
+
// Start the server
|
|
51
|
+
new server_1.GameServer();
|
|
52
|
+
const { joinAsPlayer } = yield inquirer_1.default.prompt([{
|
|
53
|
+
type: 'confirm',
|
|
54
|
+
name: 'joinAsPlayer',
|
|
55
|
+
message: '你想在本机参与游戏吗?',
|
|
56
|
+
default: true
|
|
57
|
+
}]);
|
|
58
|
+
if (joinAsPlayer) {
|
|
59
|
+
const { name } = yield inquirer_1.default.prompt([{
|
|
60
|
+
type: 'input',
|
|
61
|
+
name: 'name',
|
|
62
|
+
message: '请输入你的昵称:',
|
|
63
|
+
default: '房主'
|
|
64
|
+
}]);
|
|
65
|
+
new client_1.GameClient('http://localhost:3000', name);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const { ip, name } = yield inquirer_1.default.prompt([
|
|
70
|
+
{
|
|
71
|
+
type: 'input',
|
|
72
|
+
name: 'ip',
|
|
73
|
+
message: '请输入主机IP地址:',
|
|
74
|
+
default: '127.0.0.1'
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: 'input',
|
|
78
|
+
name: 'name',
|
|
79
|
+
message: '请输入你的昵称:',
|
|
80
|
+
}
|
|
81
|
+
]);
|
|
82
|
+
new client_1.GameClient(`http://${ip}:3000`, name);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
main().catch(console.error);
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GameServer = void 0;
|
|
4
|
+
const poker_1 = require("./game/poker");
|
|
5
|
+
const socket_io_1 = require("socket.io");
|
|
6
|
+
const http_1 = require("http");
|
|
7
|
+
const PORT = 3000;
|
|
8
|
+
class GameServer {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.players = [];
|
|
11
|
+
this.state = {
|
|
12
|
+
phase: 'waiting',
|
|
13
|
+
deck: [],
|
|
14
|
+
currentTurnIndex: 0,
|
|
15
|
+
landlordIndex: -1,
|
|
16
|
+
lastPlayedCards: [],
|
|
17
|
+
lastPlayedIndex: -1
|
|
18
|
+
};
|
|
19
|
+
const httpServer = (0, http_1.createServer)();
|
|
20
|
+
this.io = new socket_io_1.Server(httpServer);
|
|
21
|
+
this.io.on('connection', (socket) => {
|
|
22
|
+
socket.on('join_game', (name) => this.handleJoin(socket, name));
|
|
23
|
+
socket.on('disconnect', () => this.handleDisconnect(socket));
|
|
24
|
+
socket.on('player_ready', () => this.handleReady(socket));
|
|
25
|
+
// Game events
|
|
26
|
+
socket.on('bid_landlord', (bid) => this.handleBid(socket, bid));
|
|
27
|
+
socket.on('play_cards', (cards) => this.handlePlay(socket, cards));
|
|
28
|
+
socket.on('pass', () => this.handlePass(socket));
|
|
29
|
+
});
|
|
30
|
+
httpServer.listen(PORT, '0.0.0.0', () => {
|
|
31
|
+
// Logs handled in index.ts usually, but good to have here
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
handleJoin(socket, name) {
|
|
35
|
+
if (this.players.length >= 3) {
|
|
36
|
+
socket.emit('error_msg', '房间已满');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Allow joining if in waiting phase
|
|
40
|
+
if (this.state.phase !== 'waiting' && this.state.phase !== 'ended') {
|
|
41
|
+
socket.emit('error_msg', '游戏进行中');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const newPlayer = {
|
|
45
|
+
id: socket.id,
|
|
46
|
+
socket,
|
|
47
|
+
name,
|
|
48
|
+
hand: [],
|
|
49
|
+
role: 'peasant',
|
|
50
|
+
isReady: false
|
|
51
|
+
};
|
|
52
|
+
this.players.push(newPlayer);
|
|
53
|
+
console.log(`${name} 加入了游戏. (${this.players.length}/3)`);
|
|
54
|
+
// Broadcast updated player list
|
|
55
|
+
this.broadcastPlayerList();
|
|
56
|
+
// If 3 players, ask them to get ready (or auto-ready for first game?)
|
|
57
|
+
// For UX consistency, let's treat "Join" as implicitly ready for the first game if we want instant start,
|
|
58
|
+
// BUT for "Restart" feature, explicit ready is better.
|
|
59
|
+
// Let's make the first game auto-start when 3 join for simplicity,
|
|
60
|
+
// and subsequent games require explicit ready.
|
|
61
|
+
if (this.players.length === 3) {
|
|
62
|
+
console.log('房间已满,准备开始...');
|
|
63
|
+
// Mark all valid players as ready for the first game?
|
|
64
|
+
// Or just start immediately.
|
|
65
|
+
this.startGame();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
handleReady(socket) {
|
|
69
|
+
const player = this.players.find(p => p.id === socket.id);
|
|
70
|
+
if (!player)
|
|
71
|
+
return;
|
|
72
|
+
player.isReady = true;
|
|
73
|
+
console.log(`${player.name} 已准备.`);
|
|
74
|
+
// Notify others?
|
|
75
|
+
// Check if all 3 are ready
|
|
76
|
+
if (this.players.length === 3 && this.players.every(p => p.isReady)) {
|
|
77
|
+
this.startGame();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
handleDisconnect(socket) {
|
|
81
|
+
const player = this.players.find(p => p.id === socket.id);
|
|
82
|
+
this.players = this.players.filter(p => p.id !== socket.id);
|
|
83
|
+
if (player) {
|
|
84
|
+
console.log(`客户端断开连接: ${player.name} (${socket.id})`);
|
|
85
|
+
}
|
|
86
|
+
this.broadcastPlayerList();
|
|
87
|
+
// Reset game if someone leaves during play
|
|
88
|
+
if (this.state.phase !== 'waiting' && this.state.phase !== 'ended') {
|
|
89
|
+
this.io.emit('error_msg', '玩家断开连接,游戏重置。');
|
|
90
|
+
this.resetGame();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
broadcastPlayerList() {
|
|
94
|
+
const names = this.players.map(p => p.name);
|
|
95
|
+
this.io.emit('player_list_update', names);
|
|
96
|
+
}
|
|
97
|
+
startGame() {
|
|
98
|
+
console.log('所有玩家准备就绪,游戏开始!');
|
|
99
|
+
this.state.phase = 'bidding';
|
|
100
|
+
this.state.lastPlayedCards = [];
|
|
101
|
+
this.state.lastPlayedIndex = -1;
|
|
102
|
+
this.state.landlordIndex = -1;
|
|
103
|
+
this.state.deck = [];
|
|
104
|
+
// Reset ready status for next time? No, do it at end.
|
|
105
|
+
this.players.forEach(p => {
|
|
106
|
+
p.isReady = false;
|
|
107
|
+
p.role = 'peasant';
|
|
108
|
+
p.hand = [];
|
|
109
|
+
});
|
|
110
|
+
// 1. Shuffle and Deal
|
|
111
|
+
const fullDeck = (0, poker_1.shuffleDeck)((0, poker_1.createDeck)());
|
|
112
|
+
this.state.deck = fullDeck.splice(0, 3); // 3 hidden cards
|
|
113
|
+
// Deal 17 cards to each
|
|
114
|
+
this.players[0].hand = (0, poker_1.sortHand)(fullDeck.splice(0, 17));
|
|
115
|
+
this.players[1].hand = (0, poker_1.sortHand)(fullDeck.splice(0, 17));
|
|
116
|
+
this.players[2].hand = (0, poker_1.sortHand)(fullDeck.splice(0, 17));
|
|
117
|
+
this.players.forEach(p => {
|
|
118
|
+
p.socket.emit('game_start', { hand: p.hand });
|
|
119
|
+
});
|
|
120
|
+
// 2. Start Bidding (Simple random start for now)
|
|
121
|
+
this.state.currentTurnIndex = Math.floor(Math.random() * 3);
|
|
122
|
+
this.notifyTurn('bid');
|
|
123
|
+
}
|
|
124
|
+
notifyTurn(action) {
|
|
125
|
+
const player = this.players[this.state.currentTurnIndex];
|
|
126
|
+
this.io.emit('turn_update', {
|
|
127
|
+
playerIndex: this.state.currentTurnIndex,
|
|
128
|
+
playerName: player.name,
|
|
129
|
+
action
|
|
130
|
+
});
|
|
131
|
+
player.socket.emit('your_turn', { action });
|
|
132
|
+
}
|
|
133
|
+
// Placeholder handlers
|
|
134
|
+
handleBid(socket, bid) {
|
|
135
|
+
const playerIndex = this.players.findIndex(p => p.id === socket.id);
|
|
136
|
+
if (playerIndex !== this.state.currentTurnIndex)
|
|
137
|
+
return;
|
|
138
|
+
if (bid > 0) {
|
|
139
|
+
// Claimed!
|
|
140
|
+
this.state.landlordIndex = playerIndex;
|
|
141
|
+
this.players[playerIndex].role = 'landlord';
|
|
142
|
+
this.players[playerIndex].hand.push(...this.state.deck);
|
|
143
|
+
this.players[playerIndex].hand = (0, poker_1.sortHand)(this.players[playerIndex].hand);
|
|
144
|
+
this.state.phase = 'playing';
|
|
145
|
+
this.io.emit('landlord_chosen', {
|
|
146
|
+
landlordName: this.players[playerIndex].name,
|
|
147
|
+
hiddenCards: this.state.deck
|
|
148
|
+
});
|
|
149
|
+
// Sync new hand
|
|
150
|
+
this.players[playerIndex].socket.emit('hand_update', this.players[playerIndex].hand);
|
|
151
|
+
// Landlord plays first
|
|
152
|
+
this.notifyTurn('play');
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
// Pass bid
|
|
156
|
+
this.state.currentTurnIndex = (this.state.currentTurnIndex + 1) % 3;
|
|
157
|
+
// MVP: Infinite loop until someone takes it
|
|
158
|
+
this.notifyTurn('bid');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
handlePlay(socket, cards) {
|
|
162
|
+
const playerIndex = this.players.findIndex(p => p.id === socket.id);
|
|
163
|
+
if (playerIndex !== this.state.currentTurnIndex)
|
|
164
|
+
return;
|
|
165
|
+
// Validate Play
|
|
166
|
+
const { canBeat, analyzeHand } = require('./game/logic');
|
|
167
|
+
// If this is a new round (lastPlayedCards is empty), just check validity of the hand itself
|
|
168
|
+
if (this.state.lastPlayedCards.length === 0) {
|
|
169
|
+
const analysis = analyzeHand(cards);
|
|
170
|
+
if (analysis.type === 'invalid') {
|
|
171
|
+
socket.emit('error_msg', '无效的牌型!');
|
|
172
|
+
this.players[playerIndex].socket.emit('your_turn', { action: 'play' });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// Check if it beats the last played cards
|
|
178
|
+
const valid = canBeat(this.state.lastPlayedCards, cards);
|
|
179
|
+
if (!valid) {
|
|
180
|
+
socket.emit('error_msg', '出牌无效: 你的牌必须大过上家!');
|
|
181
|
+
this.players[playerIndex].socket.emit('your_turn', { action: 'play' });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
this.state.lastPlayedCards = cards;
|
|
186
|
+
this.state.lastPlayedIndex = playerIndex;
|
|
187
|
+
// Remove cards from hand
|
|
188
|
+
const p = this.players[playerIndex];
|
|
189
|
+
cards.forEach(c => {
|
|
190
|
+
const idx = p.hand.findIndex(h => h.suit === c.suit && h.rank === c.rank);
|
|
191
|
+
if (idx !== -1)
|
|
192
|
+
p.hand.splice(idx, 1);
|
|
193
|
+
});
|
|
194
|
+
p.socket.emit('hand_update', p.hand);
|
|
195
|
+
this.io.emit('player_played', {
|
|
196
|
+
playerName: p.name,
|
|
197
|
+
cards: cards,
|
|
198
|
+
remainingCount: p.hand.length
|
|
199
|
+
});
|
|
200
|
+
if (p.hand.length === 0) {
|
|
201
|
+
this.state.phase = 'ended';
|
|
202
|
+
this.io.emit('game_over', { winner: p.name, role: p.role });
|
|
203
|
+
// Don't kill server, wait for ready
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
this.state.currentTurnIndex = (this.state.currentTurnIndex + 1) % 3;
|
|
207
|
+
this.notifyTurn('play');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
handlePass(socket) {
|
|
211
|
+
const playerIndex = this.players.findIndex(p => p.id === socket.id);
|
|
212
|
+
if (playerIndex !== this.state.currentTurnIndex)
|
|
213
|
+
return;
|
|
214
|
+
if (this.state.lastPlayedIndex === playerIndex || this.state.lastPlayedIndex === -1) {
|
|
215
|
+
// Cannot pass if you started the round
|
|
216
|
+
socket.emit('error_msg', '这是新回合,必须出牌');
|
|
217
|
+
this.players[playerIndex].socket.emit('your_turn', { action: 'play' });
|
|
218
|
+
return; // Ask them to play again
|
|
219
|
+
}
|
|
220
|
+
this.io.emit('player_passed', { playerName: this.players[playerIndex].name });
|
|
221
|
+
this.state.currentTurnIndex = (this.state.currentTurnIndex + 1) % 3;
|
|
222
|
+
// If next player is the one who played last, they win the round and start new
|
|
223
|
+
if (this.state.currentTurnIndex === this.state.lastPlayedIndex) {
|
|
224
|
+
this.state.lastPlayedCards = []; // Clear
|
|
225
|
+
this.io.emit('new_round', { playerName: this.players[this.state.currentTurnIndex].name });
|
|
226
|
+
}
|
|
227
|
+
this.notifyTurn('play');
|
|
228
|
+
}
|
|
229
|
+
resetGame() {
|
|
230
|
+
this.state.phase = 'waiting';
|
|
231
|
+
this.players.forEach(p => {
|
|
232
|
+
p.hand = [];
|
|
233
|
+
p.role = 'peasant';
|
|
234
|
+
p.isReady = false;
|
|
235
|
+
});
|
|
236
|
+
this.state.deck = [];
|
|
237
|
+
this.broadcastPlayerList();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
exports.GameServer = GameServer;
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "maid-poker-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "女仆扑克牌 (Maid Poker) - A Command Line Dou Dizhu Game",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"maid-poker": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "ts-node src/index.ts",
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"game",
|
|
19
|
+
"poker",
|
|
20
|
+
"dou-dizhu",
|
|
21
|
+
"cli",
|
|
22
|
+
"multiplayer"
|
|
23
|
+
],
|
|
24
|
+
"author": "Joyeka",
|
|
25
|
+
"license": "ISC",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@types/figlet": "^1.7.0",
|
|
28
|
+
"@types/inquirer": "^9.0.9",
|
|
29
|
+
"@types/node": "^25.0.3",
|
|
30
|
+
"chalk": "^4.1.2",
|
|
31
|
+
"figlet": "^1.9.4",
|
|
32
|
+
"inquirer": "^8.2.5",
|
|
33
|
+
"socket.io": "^4.8.3",
|
|
34
|
+
"socket.io-client": "^4.8.3",
|
|
35
|
+
"ts-node": "^10.9.2",
|
|
36
|
+
"typescript": "^5.7.3"
|
|
37
|
+
}
|
|
38
|
+
}
|