dou-cl 1.0.0 → 1.0.2

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/src/app.js CHANGED
@@ -127,7 +127,7 @@ function askHuman(io, engine, state, seat, key) {
127
127
  if (skin) printStealthHelp(); else printHelp(key);
128
128
  return step();
129
129
  }
130
- if (low === 'skins' || low === 'skin' ) { printSkinHelp(); return step(); }
130
+ if (low === 'skins' || low === 'skin') { printSkinHelp(); return step(); }
131
131
  if (low === 'quit' || low === 'exit') {
132
132
  if (!skin) io.say(colors.dim('已退出本局。'));
133
133
  io.close();
@@ -258,11 +258,8 @@ function showMenu(io, opts) {
258
258
  io.say(' ' + colors.bold(m[0]) + ' ' + m[2] + ' ' + colors.dim(m[3]));
259
259
  });
260
260
  io.say('');
261
- io.say(' ' + colors.bold('联机'));
262
- io.say(' ' + colors.bold('4') + ' 创建房间 ' +
263
- colors.bold('5') + ' 加入房间');
264
- io.say(' ' + colors.bold('6') + ' 快速匹配 ' +
265
- colors.bold('7') + ' 本机开服务器');
261
+ io.say(' ' + colors.bold('联机') + colors.dim('(一个 ip:port 就是一个房间)'));
262
+ io.say(' ' + colors.bold('4') + ' 加入房间 ' + colors.bold('5') + ' 开一个房间');
266
263
  io.say('');
267
264
  io.say(' ' + colors.bold('0') + ' 退出');
268
265
  io.say(' ' + '\u2500'.repeat(58));
@@ -277,14 +274,14 @@ function showMenu(io, opts) {
277
274
  .catch(function (e) { io.say(colors.red('出错: ' + e.message)); showMenu(io, opts); });
278
275
  return;
279
276
  }
280
- if (c === '4' || c === '5' || c === '6') {
281
- onlineFlow(io, String(Number(c) - 3), opts).catch(function (e) {
277
+ if (c === '4') {
278
+ joinFlow(io, opts).catch(function (e) {
282
279
  io.say(colors.red('出错: ' + e.message));
283
280
  showMenu(io, opts);
284
281
  });
285
282
  return;
286
283
  }
287
- if (c === '7') { startServerFlow(io, opts.port); return; }
284
+ if (c === '5') { startServerFlow(io, opts); return; }
288
285
  io.say(colors.red('没有这个选项:' + c));
289
286
  setTimeout(function () { showMenu(io, opts); }, 600);
290
287
  });
@@ -296,50 +293,40 @@ function ask(io, prompt) {
296
293
  });
297
294
  }
298
295
 
299
- async function onlineFlow(io, choice, opts) {
296
+ /** 联机 · 加入房间:问昵称 + 地址,地址本身就是房间 */
297
+ async function joinFlow(io, opts) {
300
298
  io.clear();
301
299
  io.say('');
302
- const name = await ask(io, ' 你的昵称 > ') || '玩家';
303
-
304
- if (choice === '1') {
305
- io.say('');
306
- io.say(' 选择玩法: 1 斗地主 2 跑得快 3 510K');
307
- const g = await ask(io, ' > ');
308
- const key = ({ '1': 'doudizhu', '2': 'paodekuai', '3': 'fivek' })[g.trim()] || 'doudizhu';
309
- const srv = await ask(io, ' 服务器地址(直接回车 = 本机 ' + opts.server + ') > ') || opts.server;
310
- io.close();
311
- const client = require('./net/client');
312
- await client.runClient(new ui.IO(), { mode: 'create', game: key, name: name, server: srv }, opts);
313
- return;
314
- }
315
-
316
- if (choice === '2') {
317
- const code = await ask(io, ' 房间号(4 位) > ');
318
- const srv = await ask(io, ' 服务器地址(直接回车 = 本机 ' + opts.server + ') > ') || opts.server;
319
- io.close();
320
- const client = require('./net/client');
321
- await client.runClient(new ui.IO(), { mode: 'join', code: code.toUpperCase(), name: name, server: srv }, opts);
322
- return;
323
- }
324
-
300
+ io.say(' ' + colors.bold('加入房间') + colors.dim(' 填对方的 ip:port 就行'));
325
301
  io.say('');
326
- io.say(' 选择玩法: 1 斗地主 2 跑得快 3 510K');
327
- const g = await ask(io, ' > ');
328
- const key = ({ '1': 'doudizhu', '2': 'paodekuai', '3': 'fivek' })[g.trim()] || 'doudizhu';
329
- const srv = await ask(io, ' 服务器地址(直接回车 = 本机 ' + opts.server + ') > ') || opts.server;
302
+ const name = (await ask(io, ' 你的昵称 > ')) || '玩家';
303
+ const srv = (await ask(io, ' 房间地址 ip:port(回车 = ' + opts.server + ') > ')) || opts.server;
330
304
  io.close();
331
305
  const client = require('./net/client');
332
- await client.runClient(new ui.IO(), { mode: 'match', game: key, name: name, server: srv }, opts);
306
+ await client.runClient(new ui.IO(), { server: srv, name: name }, opts);
333
307
  }
334
308
 
335
- function startServerFlow(io, port) {
309
+ /** 联机 · 本机开一个房间(服务器自己不占座位,别人用 --join 连进来) */
310
+ async function startServerFlow(io, opts) {
336
311
  io.clear();
337
312
  io.say('');
338
- io.say(' 启动服务器…(Ctrl+C 停止)');
313
+ io.say(' ' + colors.bold('开一个房间') + colors.dim(' 本机起一个房间,别人连进来'));
314
+ io.say('');
315
+ io.say(' 玩法: ' + colors.bold('1') + ' 斗地主 ' + colors.bold('2') + ' 跑得快 ' + colors.bold('3') + ' 510K');
316
+ const g = await ask(io, ' > ');
317
+ const key = ({ '1': 'doudizhu', '2': 'paodekuai', '3': 'fivek' })[g.trim()] || 'doudizhu';
318
+ const p = await ask(io, ' 端口(回车 = ' + opts.port + ') > ');
319
+ const port = Number(p) || opts.port;
320
+
321
+ const rules = rulesMod.get(key);
339
322
  const server = require('./net/server');
340
- server.createServer({ port: port || 8080 });
341
- io.say(colors.dim(' 本机 IP 可以用 ipconfig 查,别人执行: npx dou-cl --join <你的IP>:' + (port || 8080)));
342
- io.say(colors.dim(' 也可以让别人快速匹配: npx dou-cl --match doudizhu --server <你的IP>:' + (port || 8080)));
323
+ server.createServers({ game: key, ports: [port] });
324
+
325
+ io.say('');
326
+ io.say(colors.dim(' 房间玩法:' + rules.name + ' 端口:' + port));
327
+ io.say(colors.dim(' 把地址发给朋友: npx dou-cl --join <你的IP>:' + port));
328
+ io.say(colors.dim(' 本机 IP 用 ipconfig 查;服务端自己不占座位。'));
329
+ io.say(colors.dim(' Ctrl+C 停止。'));
343
330
  io.handler = null;
344
331
  }
345
332
 
package/src/net/client.js CHANGED
@@ -1,13 +1,26 @@
1
1
  'use strict';
2
2
 
3
+ /*
4
+ * 联网客户端 —— 连上一个地址就是进入那个房间。
5
+ *
6
+ * npx dou-cl --join 192.168.1.10:8080
7
+ *
8
+ * 规则:**只有「出牌」需要服务器**。
9
+ * 换皮肤 / reveal / redraw / 帮助 / 退出这些都是纯本地的,任何时刻都能用 ——
10
+ * 哪怕是别人的回合、你正在等他们出牌。
11
+ */
12
+
3
13
  const WebSocket = require('ws');
4
14
  const ui = require('../ui');
5
15
  const bot = require('../bot');
6
- const gamesMod = require('../games');
7
16
  const rulesMod = require('../core/rules');
17
+ const comboMod = require('../core/combo');
18
+ const skinsMod = require('../skins');
8
19
 
9
20
  const colors = ui.colors;
10
21
 
22
+ /* ------------------------------------------------------------------ */
23
+
11
24
  function hintIds(view, seat) {
12
25
  const act = bot.hintFromView(view, seat);
13
26
  if (act && act.type === 'play' && act.ids && act.ids.length) return act.ids;
@@ -18,245 +31,370 @@ function asciiName(s, fallback) {
18
31
  return /^[\x20-\x7e]*$/.test(s || '') ? s : fallback;
19
32
  }
20
33
 
21
- async function runClient(io, opts) {
22
- const url = (opts.server.indexOf('ws://') === 0 || opts.server.indexOf('wss://') === 0)
23
- ? opts.server
24
- : 'ws://' + opts.server;
34
+ function toUrl(s) {
35
+ let v = String(s || '').trim();
36
+ if (!v) v = '127.0.0.1:8080';
37
+ if (v.indexOf('ws://') === 0 || v.indexOf('wss://') === 0) return v;
38
+ if (v.indexOf(':') < 0) v += ':8080';
39
+ return 'ws://' + v;
40
+ }
41
+
42
+ /* ------------------------------------------------------------------ */
25
43
 
44
+ async function runClient(io, opts) {
45
+ const url = toUrl(opts.server);
46
+ const addr = url.replace(/^wss?:\/\//, '');
26
47
  const skin0 = ui.skinActive();
27
- if (!skin0) io.say(colors.dim('连接中 ' + url + ' …'));
28
- else io.block(ui.skinMessage('connect svc=deck-sync addr=' + url));
48
+
49
+ if (skin0) io.block(ui.skinMessage('connect svc=deck-sync addr=' + url));
50
+ else io.say(colors.dim('连接中 ' + addr + ' …'));
29
51
 
30
52
  const ws = new WebSocket(url);
31
53
  let mySeat = -1;
32
54
  let lastView = null;
55
+ let roomInfo = null;
56
+ let gameKey = opts.game || 'doudizhu';
57
+ let leaveSent = false;
58
+ let sigHandler = null;
33
59
 
34
60
  function sendMsg(o) {
35
61
  if (ws.readyState === 1) ws.send(JSON.stringify(o));
36
62
  }
37
63
 
38
- function notify(text) {
39
- const m = ui.skinMessage(text);
64
+ /** 摸鱼模式下只输出 ASCII —— 有 en 就用 en,避免漏出中文 */
65
+ function pick(msg, en) {
66
+ return (ui.skinActive() && en) ? en : msg;
67
+ }
68
+
69
+ function notify(msg, en) {
70
+ const body = pick(msg, en);
71
+ const m = ui.skinMessage(body);
40
72
  if (m !== null) io.block(m);
41
- else io.say(colors.dim('· ' + text));
73
+ else io.say(colors.dim('· ' + body));
42
74
  }
43
75
 
44
- function complain(msg) {
45
- if (ui.skinActive()) io.block(ui.skinMessage(msg));
46
- else io.say(colors.red(msg));
76
+ function complain(msg, en) {
77
+ const body = pick(msg, en);
78
+ if (ui.skinActive()) io.block(ui.skinMessage('! ' + body));
79
+ else io.say(colors.red(body));
47
80
  }
48
81
 
49
- function promptTurn(view) {
82
+ function quit() {
83
+ if (leaveSent) return;
84
+ leaveSent = true;
85
+ sendMsg({ t: 'leave' });
86
+ try { ws.close(); } catch (e) { /* ignore */ }
87
+ }
88
+
89
+ function isQuit(line) {
90
+ const low = (line || '').trim().toLowerCase();
91
+ return low === 'q' || low === 'quit' || low === 'exit' || low === 'leave';
92
+ }
93
+
94
+ /* ---- Ctrl+C:先说一声再走(别人 / 服务器立刻知道) ---- */
95
+
96
+ sigHandler = function () {
97
+ if (leaveSent) { process.exit(0); return; }
98
+ leaveSent = true;
99
+ sendMsg({ t: 'leave' });
100
+ // 给 socket 一点时间把这句话送出去,再退出
101
+ setTimeout(function () { process.exit(0); }, 150);
102
+ };
103
+ process.on('SIGINT', sigHandler);
104
+
105
+ function dropSigint() {
106
+ if (sigHandler) { process.removeListener('SIGINT', sigHandler); sigHandler = null; }
107
+ }
108
+
109
+ /* ---- 帮助 ---- */
110
+
111
+ function printHelp(key) {
112
+ const r = rulesMod.get(key || gameKey);
113
+ const lines = [r.name + ' ' + r.tagline].concat(r.help || []);
114
+ lines.push('');
115
+ lines.push('出牌:34567 或 3 4 5 6 7 ;带花色 5s 5h 5c');
116
+ lines.push('p=不要 h=提示 ?=帮助 q=离开');
50
117
  if (ui.skinActive()) {
51
- io.ask(ui.skinPrompt(), function (line) { handleInput(line, view); });
52
- return;
118
+ const s = ui.skinHelp(lines);
119
+ if (s !== null) { process.stdout.write(s); return; }
53
120
  }
54
- const prompt = view.phase === 'bid'
55
- ? '\n叫分 (0=不叫 1/2/3=叫分, ?=帮助) > '
56
- : '\n出牌 (例: 34567 或 5 5 5 6 / p 不要 / h 提示 / ? 帮助) > ';
57
- io.ask(prompt, function (line) { handleInput(line, view); });
121
+ io.say('');
122
+ lines.forEach(function (l) { io.say(' ' + l); });
123
+ io.say('');
58
124
  }
59
125
 
60
- function handleInput(line, view) {
61
- const low = line.toLowerCase();
126
+ /* ---- 纯本地命令:任何时刻都该能用 ---- */
127
+
128
+ /** @returns true 表示这条输入已经被本地处理掉,不要当作出牌 */
129
+ function localCommand(line, view) {
130
+ const low = (line || '').trim().toLowerCase();
62
131
  const skin = ui.skinActive();
63
- if (!line) { promptTurn(view); return; }
64
132
 
65
133
  if (skin) {
66
134
  if (low === 'reveal' || low === 'real') {
67
- process.stdout.write(ui.renderTable(view, mySeat, {}) + '\n');
68
- promptTurn(view);
69
- return;
135
+ process.stdout.write(ui.renderTable(view || lastView, mySeat, {}) + '\n');
136
+ return true;
70
137
  }
71
138
  if (low === 'redraw' || low === 'rr') {
72
- io.block(ui.renderSkin(view, mySeat, {}));
73
- promptTurn(view);
74
- return;
139
+ if (view || lastView) io.block(ui.renderSkin(view || lastView, mySeat, {}));
140
+ return true;
75
141
  }
76
142
  if (low.indexOf('skin') === 0) {
77
143
  const k = low.split(/\s+/)[1];
78
144
  if (!k) {
79
- ui.skinList().forEach(function (s) { notify(s.key + ' — ' + s.desc); });
145
+ notify('skins: ' + ui.skinList().map(function (s) { return s.key; }).join(' '),
146
+ 'skins: ' + ui.skinList().map(function (s) { return s.key; }).join(' '));
80
147
  } else {
81
- try { ui.setSkin(k); notify('skin -> ' + ui.skinKey()); }
82
- catch (e) { notify(e.message); }
148
+ try { ui.setSkin(k); notify('skin -> ' + ui.skinKey(), 'skin -> ' + ui.skinKey()); }
149
+ catch (e) { notify('no such skin: ' + k, 'no such skin: ' + k); }
83
150
  }
84
- promptTurn(view);
85
- return;
151
+ return true;
86
152
  }
87
153
  }
154
+ if (low === 'help' || low === '?' || low === 'rules') { printHelp(view && view.key); return true; }
155
+ return false;
156
+ }
157
+
158
+ /* ---- 等待界面 ---- */
159
+
160
+ function askWaiting() {
161
+ const skin = ui.skinActive();
162
+ const more = !roomInfo || roomInfo.humans < roomInfo.seats;
163
+ const hint = more ? '[s] 用电脑补齐先开局 [q] 退出' : '[q] 退出';
164
+ io.ask(skin ? ui.skinPrompt() : '\n' + colors.dim(hint) + ' > ', function (line) {
165
+ const low = (line || '').trim().toLowerCase();
166
+ if (isQuit(low)) { quit(); return; }
167
+ if (low === 's' || low === 'start') { sendMsg({ t: 'start' }); return; }
168
+ if (low === '?' || low === 'help') { printHelp(roomInfo && roomInfo.game); }
169
+ askWaiting();
170
+ });
171
+ }
172
+
173
+ function renderLobby(room) {
174
+ roomInfo = room;
175
+ gameKey = room.game;
176
+ const skin = ui.skinActive();
88
177
 
89
- if (low === 'help' || low === '?' || low === 'rules') { printHelp(view.key); promptTurn(view); return; }
90
- if (low === 'quit' || low === 'exit' || low === 'leave') {
91
- sendMsg({ t: 'leave' });
92
- if (!skin) io.say('已离开房间。');
93
- ws.close();
178
+ if (skin) {
179
+ notify('room=' + addr + ' svc=deck-sync game=' + room.game +
180
+ ' seats=' + room.seats + ' ready=' + room.humans + '/' + room.seats);
181
+ const nodes = [];
182
+ for (let i = 0; i < room.seats; i++) {
183
+ const p = room.players[i];
184
+ if (!p) nodes.push('slot' + i + '=empty');
185
+ else if (p.bot) nodes.push('slot' + i + '=worker-' + i);
186
+ else if (i === mySeat) nodes.push('slot' + i + '=self');
187
+ else nodes.push('slot' + i + '=' + asciiName(p.name, 'peer' + i));
188
+ }
189
+ notify('nodes=[' + nodes.join(' ') + ']');
190
+ if (room.humans < room.seats) notify('waiting for peers ... (s=fill workers, q=leave)');
191
+ else notify('lobby ready, starting ...');
192
+ askWaiting();
94
193
  return;
95
194
  }
195
+
196
+ io.clear();
197
+ io.say('');
198
+ io.say(colors.bold(' 房间 ' + addr) + ' ' + colors.dim(room.gameName + ' · ' + room.seats + ' 人'));
199
+ io.say(' ' + '\u2500'.repeat(52));
200
+ for (let i = 0; i < room.seats; i++) {
201
+ const p = room.players[i];
202
+ let who;
203
+ if (!p) who = colors.dim('空 · 等待加入');
204
+ else if (p.bot) who = colors.dim(p.name + '(电脑)');
205
+ else if (i === mySeat) who = colors.green(p.name + '(你)');
206
+ else who = p.name;
207
+ io.say(' ' + (i + 1) + ' 号位 ' + who);
208
+ }
209
+ io.say(' ' + '\u2500'.repeat(52));
210
+ if (room.humans < room.seats) {
211
+ io.say(colors.dim(' 等待玩家 ' + room.humans + '/' + room.seats +
212
+ ' 朋友加入: npx dou-cl --join ' + addr));
213
+ } else {
214
+ io.say(colors.dim(' 人齐了,马上开始…'));
215
+ }
216
+ askWaiting();
217
+ }
218
+
219
+ /* ---- 牌局 ---- */
220
+
221
+ function promptTurn(view) {
222
+ if (ui.skinActive()) {
223
+ io.ask(ui.skinPrompt(), function (line) { onTurnInput(line, view); });
224
+ return;
225
+ }
226
+ const prompt = view.phase === 'bid'
227
+ ? '\n叫分 (0=不叫 1/2/3=叫分, ?=帮助) > '
228
+ : '\n出牌 (例: 34567 或 5 5 5 6 / p 不要 / h 提示 / ? 帮助) > ';
229
+ io.ask(prompt, function (line) { onTurnInput(line, view); });
230
+ }
231
+
232
+ /** 轮到我:本地命令优先,其余当作出牌 */
233
+ function onTurnInput(line, view) {
234
+ if (!line) { promptTurn(view); return; }
235
+ if (isQuit(line)) { quit(); return; }
236
+ if (localCommand(line, view)) { promptTurn(view); return; }
237
+ playInput(line, view);
238
+ }
239
+
240
+ /** 不是我的回合:本地命令照样能用,想打牌就告诉他还没轮到你 */
241
+ function askIdle(view) {
242
+ const prompt = ui.skinActive() ? ui.skinPrompt()
243
+ : '\n' + colors.dim('[q] 退出 本地命令随时可用: reveal / redraw / skin <名> / ?') + ' > ';
244
+ io.ask(prompt, function (line) {
245
+ if (isQuit(line)) { quit(); return; }
246
+ if (localCommand(line, view)) { askIdle(view); return; }
247
+ if ((line || '').trim()) {
248
+ const hint = ui.skinActive()
249
+ ? 'not your turn yet (local: reveal / redraw / skin <name> / help)'
250
+ : '还没轮到你。本地命令随时可用: reveal / redraw / skin <名> / ?';
251
+ notify(hint, hint);
252
+ }
253
+ askIdle(view);
254
+ });
255
+ }
256
+
257
+ function playInput(line, view) {
258
+ const low = line.toLowerCase();
259
+ const skin = ui.skinActive();
260
+
96
261
  if (view.phase === 'bid') {
97
262
  const v = parseInt(line, 10);
98
- if (isNaN(v) || v < 0 || v > 3) { complain('叫分只能是 0/1/2/3'); promptTurn(view); return; }
263
+ if (isNaN(v) || v < 0 || v > 3) {
264
+ complain('叫分只能是 0/1/2/3', 'bid must be 0 / 1 / 2 / 3');
265
+ promptTurn(view);
266
+ return;
267
+ }
99
268
  sendMsg({ t: 'act', action: { type: 'bid', score: v } });
100
269
  return;
101
270
  }
102
- if (low === 'p' || low === 'pass' || line === '不要' || line === '过') {
103
- if (!view.canPass) { complain('你是先手,必须出牌'); promptTurn(view); return; }
271
+ if (low === 'p' || low === 'pass' || line === '不要' || line === '过' || line === '不出') {
272
+ if (!view.canPass) {
273
+ complain('你是先手,必须出牌', 'you lead this round, must play');
274
+ promptTurn(view);
275
+ return;
276
+ }
104
277
  sendMsg({ t: 'act', action: { type: 'pass' } });
105
278
  return;
106
279
  }
107
280
  if (low === 'h' || low === 'hint' || line === '提示') {
108
281
  const ids = hintIds(view, mySeat);
109
282
  if (!ids || !ids.length) {
110
- complain('没有能管上的牌,只能不要(pass)');
283
+ complain('没有能管上的牌,只能不要(pass)', 'nothing beats it, must pass');
111
284
  } else {
112
285
  const cards = view.hand.filter(function (c) { return ids.indexOf(c.id) >= 0; });
113
- const combo = require('../core/combo');
114
- const co = combo.analyze(cards, rulesMod.get(view.key));
115
- const txt = 'hint: ' + ui.handText(cards) + ' [' + (co ? combo.typeName(co.type) : '') + ']';
116
- if (skin) io.block(ui.skinMessage(txt)); else io.say(txt);
286
+ const co = comboMod.analyze(cards, rulesMod.get(view.key));
287
+ if (skin) {
288
+ const txt = 'hint ' + cards.map(function (c) { return skinsMod.packet.cardCode(c); }).join(' ') +
289
+ ' [' + (co ? co.type : '') + ']';
290
+ io.block(ui.skinMessage(txt));
291
+ } else {
292
+ io.say('hint: ' + ui.handText(cards) + ' [' + (co ? comboMod.typeName(co.type) : '') + ']');
293
+ }
117
294
  }
118
295
  promptTurn(view);
119
296
  return;
120
297
  }
121
298
 
122
299
  const parsed = ui.parseCardInput(line, view.hand);
123
- if (!parsed.ok) { complain(parsed.err); promptTurn(view); return; }
300
+ if (!parsed.ok) { complain(parsed.err, parsed.en); promptTurn(view); return; }
124
301
  sendMsg({ t: 'act', action: { type: 'play', ids: parsed.ids } });
125
302
  }
126
303
 
127
- function printHelp(key) {
128
- const r = rulesMod.get(key);
129
- const lines = [r.name + ' ' + r.tagline].concat(r.help);
130
- lines.push('');
131
- lines.push('出牌:34567 或 3 4 5 6 7 ;带花色 5s 5h 5c');
132
- lines.push('p=不要 h=提示 ?=帮助 quit=离开');
133
- if (ui.skinActive()) {
134
- const s = ui.skinHelp(lines);
135
- if (s !== null) { process.stdout.write(s); return; }
136
- }
137
- io.say('');
138
- lines.forEach(function (l) { io.say(' ' + l); });
139
- io.say('');
140
- }
141
-
142
304
  function renderState(view) {
143
305
  lastView = view;
144
306
  const skin = ui.skinActive();
145
307
  const skinned = ui.renderSkin(view, mySeat, {});
146
- if (skinned !== null) {
147
- io.block(skinned);
148
- } else {
149
- io.clear();
150
- process.stdout.write(ui.renderTable(view, mySeat, {}) + '\n');
308
+ if (skinned !== null) io.block(skinned);
309
+ else { io.clear(); process.stdout.write(ui.renderTable(view, mySeat, {}) + '\n'); }
310
+
311
+ if (view.over) {
312
+ const txt = (view.result && view.result.text) || '';
313
+ if (skin) notify('round done next round soon (q=leave)', 'round done next round soon (q=leave)');
314
+ else io.say(colors.dim(' 本局结束,马上开始下一局…(q 退出)'));
315
+ askIdle(view);
316
+ return;
151
317
  }
152
- if (view.over) { askAgain(); return; }
153
318
  if (view.turn === mySeat && view.phase !== 'over') {
154
319
  promptTurn(view);
155
320
  } else {
156
- io.handler = null;
157
321
  const who = view.seats[view.turn] ? view.seats[view.turn].name : '?';
158
- if (skin) io.block(ui.skinMessage('waiting for ' + asciiName(who, 'peer') + ' ...'));
322
+ if (skin) notify('waiting for ' + asciiName(who, 'peer') + ' ...',
323
+ 'waiting for ' + asciiName(who, 'peer') + ' ...');
159
324
  else process.stdout.write(colors.dim(' 等待 ' + who + (view.phase === 'bid' ? ' 叫分…' : ' 出牌…')) + '\n');
325
+ askIdle(view);
160
326
  }
161
327
  }
162
328
 
163
- function askAgain() {
164
- io.ask(ui.skinActive() ? ui.skinPrompt() : '\n再来一局?(y/n) > ', function (line) {
165
- sendMsg({ t: 'again', yes: /^y/i.test(line.trim()) });
166
- });
167
- }
168
-
169
- function renderLobby(room) {
170
- if (ui.skinActive()) {
171
- notify('pool=' + room.code + ' svc=deck-sync game=' + room.game + ' seats=' + room.seats);
172
- const nodes = [];
173
- for (let i = 0; i < room.seats; i++) {
174
- const p = room.players[i];
175
- if (!p) nodes.push('slot' + i + '=empty');
176
- else if (p.bot) nodes.push('slot' + i + '=worker-' + i);
177
- else nodes.push('slot' + i + '=' + asciiName(p.name, i === room.hostSeat ? 'self' : ('peer' + i)));
178
- }
179
- notify('nodes=[' + nodes.join(' ') + ']');
180
- return;
181
- }
182
- io.clear();
183
- io.say('');
184
- io.say(colors.bold(' 房间 ' + room.code) + ' ' + colors.dim(room.gameName + ' · ' + room.seats + ' 人'));
185
- io.say(' ' + '\u2500'.repeat(50));
186
- for (let i = 0; i < room.seats; i++) {
187
- const p = room.players[i];
188
- io.say(' ' + (i + 1) + '号位 ' + (p ? (p.bot ? colors.dim(p.name + '(电脑)') : p.name) : colors.dim('空')));
189
- }
190
- io.say(' ' + '\u2500'.repeat(50));
191
- io.say(colors.dim(' 把房间号发给朋友,或让他们执行: npx dou-cl --join <地址> --code ' + room.code));
192
- }
329
+ /* ---- 连接 ---- */
193
330
 
194
331
  ws.on('open', function () {
195
- if (!ui.skinActive()) { io.clear(); io.say(colors.green('已连上服务器。')); }
196
- if (opts.mode === 'match') sendMsg({ t: 'match', game: opts.game, name: opts.name });
197
- else if (opts.mode === 'join') sendMsg({ t: 'join', code: opts.code, name: opts.name });
198
- else sendMsg({ t: 'create', game: opts.game, name: opts.name, public: false });
332
+ if (!ui.skinActive()) { io.clear(); io.say(colors.green('已连上 ' + addr + ',正在进入房间…')); }
333
+ sendMsg({ t: 'join', name: opts.name });
199
334
  });
200
335
 
201
336
  ws.on('message', function (raw) {
202
337
  let m;
203
338
  try { m = JSON.parse(String(raw)); } catch (e) { return; }
204
339
 
205
- if (m.t === 'hello') { opts.games = m.games; return; }
340
+ if (m.t === 'hello') { gameKey = m.game; return; }
206
341
 
207
- if (m.t === 'joined') {
342
+ if (m.t === 'seated') {
208
343
  mySeat = m.seat;
209
- notify('joined pool=' + m.code + ' slot=' + m.seat);
344
+ if (ui.skinActive()) notify('seated room=' + addr + ' slot=' + m.seat + ' game=' + gameKey);
210
345
  return;
211
346
  }
212
347
 
213
- if (m.t === 'room' || m.t === 'lobby') {
214
- if (lastView && !lastView.over) return;
348
+ if (m.t === 'lobby') {
349
+ if (lastView && lastView.over) lastView = null;
350
+ if (m.room.started) return; // 已经开局了,等 state
215
351
  renderLobby(m.room);
216
- const isHost = mySeat >= 0 && m.room.hostSeat === mySeat;
217
- if (isHost) {
218
- io.ask(ui.skinActive() ? ui.skinPrompt()
219
- : '\n' + colors.bold('按 Enter 开始游戏(不足的位置由电脑补上)> '),
220
- function (line) {
221
- const low = line.trim().toLowerCase();
222
- if (low === 'q' || low === 'quit') { sendMsg({ t: 'leave' }); ws.close(); return; }
223
- sendMsg({ t: 'start' });
224
- });
225
- } else {
226
- io.handler = null;
227
- if (ui.skinActive()) io.block(ui.skinMessage('waiting for host ...'));
228
- else io.say(colors.dim(' 等房主开始…(输入 quit 离开)'));
229
- }
230
352
  return;
231
353
  }
232
354
 
233
355
  if (m.t === 'state') { renderState(m.view); return; }
234
- if (m.t === 'notice') { notify(m.msg); return; }
356
+ if (m.t === 'notice') { notify(m.msg, m.en); return; }
357
+
235
358
  if (m.t === 'chat') {
236
359
  if (ui.skinActive()) notify(m.who + ': ' + m.msg);
237
360
  else io.say(colors.cyan('[' + m.who + '] ') + m.msg);
238
361
  return;
239
362
  }
363
+
364
+ if (m.t === 'full') {
365
+ complain(m.msg, m.en);
366
+ if (!ui.skinActive()) {
367
+ io.say(colors.dim(' 换一个端口,或稍后再来(房间空了就能进)。'));
368
+ }
369
+ setTimeout(function () { try { ws.close(); } catch (e) { /* ignore */ } }, 150);
370
+ return;
371
+ }
372
+
240
373
  if (m.t === 'error') {
241
- complain(m.msg.indexOf('!') === 0 ? m.msg : ('! ' + m.msg));
242
- if (mySeat < 0) ws.close();
243
- else if (lastView) promptTurn(lastView);
374
+ complain(m.msg, m.en);
375
+ if (mySeat < 0) setTimeout(function () { try { ws.close(); } catch (e) { /* ignore */ } }, 150);
376
+ else if (lastView && !lastView.over) promptTurn(lastView);
244
377
  return;
245
378
  }
246
379
  });
247
380
 
248
381
  ws.on('close', function () {
382
+ dropSigint();
249
383
  if (!ui.skinActive()) io.say(colors.dim('与服务器的连接已断开。'));
250
384
  io.close();
251
385
  });
252
386
 
253
387
  ws.on('error', function (e) {
388
+ dropSigint();
254
389
  if (!ui.skinActive()) {
255
390
  io.say(colors.red('连接失败: ' + e.message));
256
- io.say(colors.dim('请确认地址/端口正确,且服务端已启动(npx dou-cl --serve)。'));
391
+ io.say(colors.dim('确认地址/端口正确,且那边已经开了房间(npx dou-cl --serve --port ' +
392
+ (addr.split(':')[1] || '8080') + ')。'));
393
+ } else {
394
+ io.block(ui.skinMessage('! connect failed: ' + e.message));
257
395
  }
258
396
  io.close();
259
397
  });
260
398
  }
261
399
 
262
- module.exports = { runClient: runClient };
400
+ module.exports = { runClient: runClient, toUrl: toUrl };