@pdc-test/chat-io 1.0.2 → 1.1.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/bin/cli.js CHANGED
@@ -1,140 +1,302 @@
1
1
  #!/usr/bin/env node
2
-
3
2
  const WebSocket = require('ws');
4
3
  const readline = require('readline');
5
4
 
6
- // URL del Servidor Oficial de Railway
5
+ // ============================================
6
+ // CLIENTE MODULAR RÁPIDO (Carga NATIVA JSON)
7
+ // ============================================
7
8
  const SERVER_URL = 'wss://chat-io-production.up.railway.app/';
8
-
9
9
  const ws = new WebSocket(SERVER_URL);
10
- const commands = ['/users', '/flip', '/rps', '/tiendita', '/help', '/w', '/blink'];
11
10
 
12
- // Autocompletado del Teclado (TAB)
11
+ let myName = null;
12
+ let currentIsTyping = false;
13
+ let typingTimeout = null;
14
+
15
+ // Control de UI Local
16
+ let activeTypers = [];
17
+ let tIdx = 0;
18
+ const typingFrames = ['.', '..', '...'];
19
+ let isMultilinePrompt = false;
20
+
21
+ // Helpers VISUALES
22
+ function getTime() {
23
+ const d = new Date();
24
+ const hs = String(d.getHours()).padStart(2, '0');
25
+ const ms = String(d.getMinutes()).padStart(2, '0');
26
+ return `\x1b[90m${hs}:${ms}\x1b[0m`;
27
+ }
28
+
29
+ function clearTopLine() {
30
+ readline.moveCursor(process.stdout, 0, -1);
31
+ readline.clearLine(process.stdout, 0);
32
+ readline.moveCursor(process.stdout, 0, 1);
33
+ }
34
+
35
+ // Interfaz del Teclado Local
36
+ const commands = ['/users', '/flip', '/rps', '/tiendita', '/help', '/w', '/all', '/blink', '/exit', '/clear'];
37
+ let knownUsersList = [];
38
+
13
39
  function completer(line) {
14
- const hits = commands.filter((c) => c.startsWith(line));
40
+ const tokens = line.split(" ");
41
+ let completions = [];
42
+
43
+ if (line.startsWith("@")) {
44
+ completions = knownUsersList.map(u => "@" + u);
45
+ } else if (line.toLowerCase().startsWith("/w ") || line.toLowerCase().startsWith("/chat ")) {
46
+ completions = knownUsersList.map(u => tokens[0] + " " + u + " ");
47
+ } else if (line.toLowerCase().startsWith("/rps ")) {
48
+ if (tokens.length === 2) {
49
+ completions = knownUsersList.map(u => "/rps " + u + " ");
50
+ } else if (tokens.length === 3) {
51
+ const rpsOpts = ["piedra", "papel", "tijera"];
52
+ completions = rpsOpts.map(o => `/rps ${tokens[1]} ${o}`);
53
+ }
54
+ } else if (line.includes("@")) {
55
+ const lastAt = line.lastIndexOf("@");
56
+ const prefix = line.substring(0, lastAt);
57
+ completions = knownUsersList.map(u => prefix + "@" + u + " ");
58
+ } else {
59
+ completions = [...commands, ...knownUsersList.map(u => "/w " + u)];
60
+ }
61
+
62
+ const hits = completions.filter((c) => c.toLowerCase().startsWith(line.toLowerCase()));
15
63
  return [hits.length ? hits : [], line];
16
64
  }
17
65
 
18
- const rl = readline.createInterface({
19
- input: process.stdin,
20
- output: process.stdout,
21
- completer,
22
- prompt: '\r> ' // Prefix visual del input
23
- });
66
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, completer, prompt: '> ', removeHistoryDuplicates: true });
24
67
 
25
- let isTyping = false;
26
- let typingTimeout = null;
27
- let typingUsers = new Set();
28
- let userTypingTimers = {};
29
- let typingFrames = ['.', '..', '...'];
30
- let tIdx = 0;
31
- let isMultilinePrompt = false;
68
+ rl.on('SIGINT', () => {
69
+ printMessage("\x1b[33mSaliendo...\x1b[0m");
70
+ ws.close();
71
+ process.exit(0);
72
+ });
32
73
 
33
- // Escuchador de teclado constante (Raw Mode Mágico)
34
74
  process.stdin.on('keypress', (str, key) => {
35
- // Detectamos cualquier tecla que no presione Enter
36
- if (key && key.name !== 'return' && key.name !== 'enter') {
37
- if (!isTyping) {
38
- isTyping = true;
39
- if (ws.readyState === WebSocket.OPEN) {
40
- ws.send("/typ"); // Ping silencioso
41
- }
75
+ if (key && key.name !== 'return' && key.name !== 'enter' && key.name !== 'c' && myName) {
76
+ if (!currentIsTyping && ws.readyState === WebSocket.OPEN) {
77
+ currentIsTyping = true;
78
+ ws.send(JSON.stringify({ type: "typing" }));
42
79
  }
43
- // Reiniciar el timeout si el usuario sigue tecleando
44
80
  clearTimeout(typingTimeout);
45
81
  typingTimeout = setTimeout(() => {
46
- isTyping = false;
47
- if (ws.readyState === WebSocket.OPEN) ws.send("/typ_stop");
82
+ currentIsTyping = false;
83
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "typing_stop" }));
48
84
  }, 1500);
49
85
  }
50
86
  });
51
87
 
52
- // Renderizado de "Escribiendo..." en la parte SUPERIOR (En el Prompt)
88
+ // Actualizador de UI Typing Background -> Puesto a 300ms, Cero Network Latency porque es local
53
89
  setInterval(() => {
54
- if (typingUsers.size > 0) {
55
- const users = Array.from(typingUsers).join(", ");
56
- const suffix = users.includes(",") ? "están escribiendo" : "está escribiendo";
57
-
58
- rl.setPrompt(`\x1b[90m[${users} ${suffix} ${typingFrames[tIdx]}]\x1b[0m\n> `);
90
+ if (activeTypers.length > 0) {
91
+ const usersInfo = activeTypers.join(", ") + (activeTypers.length > 1 ? " están" : " está") + " escribiendo";
92
+ rl.setPrompt(`\x1b[90m[${usersInfo} ${typingFrames[tIdx]}]\x1b[0m\n> `);
59
93
  tIdx = (tIdx + 1) % typingFrames.length;
60
94
  rl.prompt(true);
61
95
  isMultilinePrompt = true;
62
96
  } else {
63
97
  if (isMultilinePrompt) {
64
98
  rl.setPrompt('> ');
65
- // Borrar el fantasma superior que dejó el prompt multilinea
66
- readline.moveCursor(process.stdout, 0, -1);
67
- readline.clearLine(process.stdout, 0);
68
- readline.moveCursor(process.stdout, 0, 1);
99
+ clearTopLine();
69
100
  rl.prompt(true);
70
101
  isMultilinePrompt = false;
71
102
  }
72
103
  }
73
- }, 400);
104
+ }, 300);
74
105
 
75
- // Flujo WebSocket
106
+ // Flujo Principal Socket
76
107
  ws.on('open', () => {
77
- // Nos identificamos en secreto como un Cliente VIP Inteligente
78
- ws.send("/iam_smart");
79
- // No necesitamos imprimir conexión local, el servidor de Railway enviará el cohete.
108
+ console.log("\x1b[36m====================================\x1b[0m");
109
+ console.log(" 🚀 BIENVENIDO AL JSON-CHAT-CLI 🚀");
110
+ console.log("\x1b[36m====================================\x1b[0m");
111
+ console.log("🤖 Sistema: Escribe tu nombre y presiona Enter para comenzar:");
112
+ rl.prompt();
80
113
  });
81
114
 
82
- ws.on('message', (data) => {
83
- const msg = data.toString();
84
-
85
- // Si el servidor nos avisa secretamente que alguien tipeó:
86
- if (msg.startsWith("/typ_event ")) {
87
- const u = msg.replace("/typ_event ", "").trim();
88
- typingUsers.add(u);
89
- clearTimeout(userTypingTimers[u]);
90
- userTypingTimers[u] = setTimeout(() => typingUsers.delete(u), 3500);
91
- return;
92
- }
93
-
94
- if (msg.startsWith("/typ_stop_event ")) {
95
- const u = msg.replace("/typ_stop_event ", "").trim();
96
- typingUsers.delete(u);
97
- clearTimeout(userTypingTimers[u]);
98
- return;
99
- }
100
-
101
- // Si recibimos texto normal, limpiamos todas las señales tácticas
102
- typingUsers.clear();
103
-
104
- // Purgamos toda visualización flotante o entrada corrupta antes de dibujar texto nuevo
105
- if (isMultilinePrompt) {
106
- readline.moveCursor(process.stdout, 0, -1);
107
- readline.clearLine(process.stdout, 0);
108
- readline.moveCursor(process.stdout, 0, 1);
109
- isMultilinePrompt = false;
110
- }
115
+ // Encargado de Pintar Todo en la Terminal limpiamente
116
+ function printMessage(msgStr) {
117
+ if (isMultilinePrompt) { clearTopLine(); isMultilinePrompt = false; }
111
118
  readline.clearLine(process.stdout, 0);
112
119
  readline.cursorTo(process.stdout, 0);
113
-
114
- console.log(msg);
120
+ console.log(msgStr);
115
121
  rl.setPrompt('> ');
116
- rl.prompt(true);
122
+ rl.prompt(true);
123
+ }
124
+
125
+ // Receptor de la Nube (Solo procesa comandos JSON Puros, nada de basura String)
126
+ ws.on('message', (data) => {
127
+ let res;
128
+ try { res = JSON.parse(data.toString()); } catch(e) { return; }
129
+
130
+ switch (res.type) {
131
+ case "error": printMessage(`❌ \x1b[31m${res.msg}\x1b[0m`); break;
132
+
133
+ case "system": printMessage(`\n[${getTime()}] ${res.msg}`); break;
134
+
135
+ case "registered":
136
+ myName = res.name;
137
+ printMessage(`\n✅ ¡Listo ${res.name}! Estás con el avatar ${res.emoji}. Escribe /help para los comandos nativos.`);
138
+ break;
139
+
140
+ case "typing_event":
141
+ // El servidor nos entrega el arreglo COMPLETO y VIVO de quién tipea
142
+ // Tu computadora solo limpia e ignora inteligentemente si está tu mismo nombre de pila
143
+ activeTypers = res.users.filter(u => u !== myName);
144
+ break;
145
+
146
+ case "users_update":
147
+ if (res.plain) knownUsersList = res.plain.filter(u => u !== myName);
148
+ break;
149
+
150
+ case "ding":
151
+ process.stdout.write("\x07"); // ASCII Bell físico disparado remotamente
152
+ break;
153
+
154
+ case "users_list":
155
+ printMessage(`\n👥 \x1b[36mCONECTADOS AHORA (${res.users.length}):\x1b[0m ` + res.users.join(", ") + "\n");
156
+ break;
157
+
158
+ case "chat":
159
+ // Todo el poder de Parsear Regex se procesa en TU PC, ahorrándole billones de cálculos al Server
160
+ let safeMsg = res.msg.replace(/@([a-zA-Z0-9_]+)/g, "\x1b[1m\x1b[33m@$1\x1b[0m");
161
+ if (res.isWhisper) {
162
+ const dir = res.from === myName ? `Private → ${res.to}` : `${res.emoji} Secreto de ${res.from}`;
163
+ printMessage(`\n[${getTime()}] 🔒 [${dir}]: \x1b[35m${safeMsg}\x1b[0m`);
164
+ } else {
165
+ printMessage(`\n[${getTime()}] 🌍 [${res.emoji} ${res.from}]: ${safeMsg}`);
166
+ }
167
+ break;
168
+
169
+ case "animation":
170
+ if (res.name === "tiendita") renderTiendita(res.user);
171
+ else if (res.name === "flip") renderFlip(res.user, res.emoji, res.result);
172
+ break;
173
+ }
117
174
  });
118
175
 
119
- // Cuando le damos ENTER
176
+ // ANIMACIONES RENDERIZADAS LOCALMENTE CON CPU CLIENT (LATENCIA CERO, NO SUCIA LA RED)
177
+ function renderTiendita(user) {
178
+ if (isMultilinePrompt) { clearTopLine(); isMultilinePrompt = false; }
179
+ readline.clearLine(process.stdout, 0);
180
+ readline.cursorTo(process.stdout, 0);
181
+
182
+ console.log(`\n[${getTime()}] 🏪 \x1b[1m${user}\x1b[0m convoca a la tiendita...\n`);
183
+
184
+ const asciiArt = `_____ _ _ _ _ _ _ _ \n |_ _(_) ___ _ __ __| (_) |_ __ _| | | |\n | | | |/ _ \\ '_ \\ / _ | | __/ _ | | | |\n | | | | __/ | | | (_ | | || (_ |_|_|_|\n |_| |_|\\___|_| |_|\\__,_|_|\\__\\__,_(_|_|_)\n `;
185
+ const colors = ["\x1b[31m", "\x1b[33m", "\x1b[32m", "\x1b[36m", "\x1b[1m\x1b[35m"];
186
+
187
+ // Dejar huella Base
188
+ process.stdout.write(`${colors[0]}${asciiArt}\x1b[0m\n`);
189
+
190
+ let frames = 0;
191
+ const anim = setInterval(() => {
192
+ frames++;
193
+ const c = colors[frames % colors.length];
194
+ // Bucle mágico hiper-veloz usando RAM Local
195
+ process.stdout.write(`\r\x1b[6A\x1b[0J${c}${asciiArt}\x1b[0m\n`);
196
+ if (frames > 15) {
197
+ clearInterval(anim);
198
+ rl.prompt(true);
199
+ }
200
+ }, 150); // Mismo efecto, pero procesado x100 más eficiente
201
+ }
202
+
203
+ function renderFlip(user, emoji, result) {
204
+ if (isMultilinePrompt) { clearTopLine(); isMultilinePrompt = false; }
205
+ readline.clearLine(process.stdout, 0);
206
+ readline.cursorTo(process.stdout, 0);
207
+ console.log(`\n[${getTime()}] 🪙 [${emoji} ${user}] lanza una moneda al aire...`);
208
+
209
+ const asciiCoins = ["\x1b[33m ( o ) \x1b[0m", "\x1b[33m ( | ) \x1b[0m", "\x1b[38;5;220m ( 0 ) \x1b[0m", "\x1b[33m ( | ) \x1b[0m"];
210
+ let i = 0;
211
+
212
+ process.stdout.write(" \n");
213
+ const anim = setInterval(() => {
214
+ process.stdout.write(`\r\x1b[1A\x1b[K ${asciiCoins[i % 4]} zumbando...\n`);
215
+ i++;
216
+ if (i > 15) {
217
+ clearInterval(anim);
218
+ process.stdout.write(`\r\x1b[1A\x1b[K[${getTime()}] 🎯 ¡CAYÓ LA MONEDA de ${user}! Es -> \x1b[1m\x1b[33m${result}\x1b[0m\n\n`);
219
+ rl.prompt(true);
220
+ }
221
+ }, 100);
222
+ }
223
+
224
+ // Control General del Botón Enter (Distribuidor Estructurado)
120
225
  rl.on('line', (input) => {
121
- // Borramos la línea que acabas de teclear para que no se duplique
122
- // con el mensaje que el servidor nos devolverá en azul
226
+ // Destruimos el "echo" por defecto para no dejar basura arriba del prompt
123
227
  readline.moveCursor(process.stdout, 0, -1);
124
228
  readline.clearLine(process.stdout, 0);
125
229
 
126
230
  const line = input.trim();
127
- if (line) {
128
- ws.send(line);
129
- isTyping = false;
130
- ws.send("/typ_stop");
231
+ if (!line) { rl.prompt(true); return; }
232
+
233
+ // Flujo Inicial Obligatorio
234
+ if (!myName) {
235
+ ws.send(JSON.stringify({ type: "register", name: line }));
236
+ return;
131
237
  }
132
- rl.prompt(true);
238
+
239
+ // Local Helper Menú
240
+ if (line.toLowerCase() === "/clear") { console.clear(); rl.setPrompt('> '); rl.prompt(true); return; }
241
+ if (line.toLowerCase() === "/exit") {
242
+ printMessage("\x1b[33mSaliendo...\x1b[0m");
243
+ ws.close();
244
+ process.exit(0);
245
+ }
246
+ if (line.toLowerCase() === "/help") {
247
+ printMessage(`\n──────────── 💡 COMANDOS NATIVOS ────────────
248
+ 👥 /users → Ver conectados remotamente
249
+ 🪙 /flip → Lanzar moneda 60FPS
250
+ 🎮 /rps <usr> <j> → Piedra-Papel-Tijera
251
+ 🌟 /blink <msj> → Envía un texto parpadeante ANSI
252
+ 🔒 /w <usr> [msj] → Modo Susurro directo a una persona
253
+ 🌍 /all → Volver a la Sala Global Abierta
254
+ 🏪 /tiendita → Letrero de Neón de colores acelerado
255
+ 🧹 /clear → Limpia historial de consola
256
+ ❌ /exit → Salir del chat
257
+ ℹ️ /help → Te ayuda desde la memoria Caché
258
+ ─────────────────────────────────────────────\n`);
259
+ return;
260
+ }
261
+
262
+ // Constructor Arquitectónico y Limpio de Comandos a Servidor
263
+ if (line.startsWith("/")) {
264
+ const lowerLine = line.toLowerCase();
265
+ if (lowerLine === "/users" || lowerLine === "/flip" || lowerLine === "/tiendita") {
266
+ ws.send(JSON.stringify({ type: "command", cmd: lowerLine.substring(1) }));
267
+ } else if (lowerLine === "/all") {
268
+ ws.send(JSON.stringify({ type: "command", cmd: "mode_all" }));
269
+ } else if (lowerLine.startsWith("/w ")) {
270
+ const p = line.split(" ");
271
+ if (p.length === 2) {
272
+ ws.send(JSON.stringify({ type: "command", cmd: "mode_whisper", target: p[1] }));
273
+ } else if (p.length > 2) {
274
+ const inlineMsg = p.slice(2).join(" ").trim();
275
+ if (inlineMsg) {
276
+ ws.send(JSON.stringify({ type: "chat", target: p[1], msg: inlineMsg }));
277
+ } else {
278
+ ws.send(JSON.stringify({ type: "command", cmd: "mode_whisper", target: p[1] }));
279
+ }
280
+ }
281
+ } else if (lowerLine.startsWith("/rps ")) {
282
+ const p = line.split(" ");
283
+ if (p.length >= 3) ws.send(JSON.stringify({ type: "command", cmd: "rps", target: p[1], choice: p[2].toLowerCase() }));
284
+ } else if (lowerLine.startsWith("/blink ")) {
285
+ ws.send(JSON.stringify({ type: "command", cmd: "blink", msg: line.substring(7) }));
286
+ } else {
287
+ printMessage(`❌ Comando desconocido. Intenta /help`);
288
+ }
289
+ } else {
290
+ // Mensaje Casual
291
+ ws.send(JSON.stringify({ type: "chat", msg: line }));
292
+ }
293
+
294
+ isTyping = false;
295
+ ws.send(JSON.stringify({ type: "typing_stop" }));
133
296
  });
134
297
 
298
+ // Salvavidas de Desconexión Rápida
135
299
  ws.on('close', () => {
136
- readline.clearLine(process.stdout, 0);
137
- readline.cursorTo(process.stdout, 0);
138
- console.log("\n\x1b[31m[!] Se ha perdido la conexión con la Nube.\x1b[0m");
300
+ printMessage("\x1b[31m[!] Oh no... Acabas de perder la conexión con la Matriz (Nube principal caída).\x1b[0m");
139
301
  process.exit(0);
140
302
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pdc-test/chat-io",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "main": "./src/index.js",
5
5
  "bin": {
6
6
  "chat-io": "bin/cli.js"
@@ -9,7 +9,7 @@
9
9
  "start": "node src/server/index.js"
10
10
  },
11
11
  "dependencies": {
12
- "@pdc-test/chat-io": "^1.0.2",
12
+ "@pdc-test/chat-io": "^1.1.1",
13
13
  "crypto-js": "^4.2.0",
14
14
  "ws": "^8.0.0"
15
15
  }
package/src/chat/index.js CHANGED
@@ -24,19 +24,39 @@ function startChat(onExit, mode = 'udp') {
24
24
  let animationInterval;
25
25
 
26
26
  function completer(line) {
27
- const completions = [
28
- ...Array.from(knownUsers).map(u => `/chat ${u}`),
29
- ...Array.from(knownUsers).map(u => `/rps ${u} `),
30
- "/all", "/clear", "/help", "/users", "/exit", "/flip", "/blink ", "/chat ", "/rps "
31
- ];
32
- const hits = completions.filter((c) => c.startsWith(line));
27
+ const tokens = line.split(" ");
28
+ let completions = [];
29
+ const cmds = ["/all", "/clear", "/help", "/users", "/exit", "/flip", "/blink", "/chat", "/rps", "/w"];
30
+ const users = Array.from(knownUsers);
31
+
32
+ if (line.startsWith("@")) {
33
+ completions = users.map(u => "@" + u);
34
+ } else if (line.toLowerCase().startsWith("/w ") || line.toLowerCase().startsWith("/chat ")) {
35
+ completions = users.map(u => tokens[0] + " " + u + " ");
36
+ } else if (line.toLowerCase().startsWith("/rps ")) {
37
+ if (tokens.length === 2) {
38
+ completions = users.map(u => "/rps " + u + " ");
39
+ } else if (tokens.length === 3) {
40
+ const rpsOpts = ["piedra", "papel", "tijera"];
41
+ completions = rpsOpts.map(o => `/rps ${tokens[1]} ${o}`);
42
+ }
43
+ } else if (line.includes("@")) {
44
+ const lastAt = line.lastIndexOf("@");
45
+ const prefix = line.substring(0, lastAt);
46
+ completions = users.map(u => prefix + "@" + u + " ");
47
+ } else {
48
+ completions = [...cmds, ...users.map(u => "/w " + u)];
49
+ }
50
+
51
+ const hits = completions.filter((c) => c.toLowerCase().startsWith(line.toLowerCase()));
33
52
  return [hits.length ? hits : completions, line];
34
53
  }
35
54
 
36
55
  const rl = readline.createInterface({
37
56
  input: process.stdin,
38
57
  output: process.stdout,
39
- completer: completer
58
+ completer: completer,
59
+ removeHistoryDuplicates: true
40
60
  });
41
61
 
42
62
  animationInterval = setInterval(() => {
@@ -114,7 +134,10 @@ function startChat(onExit, mode = 'udp') {
114
134
  return;
115
135
  }
116
136
 
117
- if (data.type === "rps_challenge" && data.to === username) {
137
+ // Resolving case insensitivity for target
138
+ const isTargetMe = data.to && data.to.toLowerCase() === username.toLowerCase();
139
+
140
+ if (data.type === "rps_challenge" && isTargetMe) {
118
141
  incomingRPS.add(data.from);
119
142
  readline.clearLine(process.stdout, 0);
120
143
  readline.cursorTo(process.stdout, 0);
@@ -124,11 +147,13 @@ function startChat(onExit, mode = 'udp') {
124
147
  return;
125
148
  }
126
149
 
127
- if (data.type === "rps_response" && data.to === username) {
128
- if (pendingRPS.has(data.from)) {
129
- const miJugada = pendingRPS.get(data.from).miJugada;
150
+ if (data.type === "rps_response" && isTargetMe) {
151
+ // Allow case insensitive checking for pendingRPS
152
+ const pendingKey = Array.from(pendingRPS.keys()).find(k => k.toLowerCase() === data.from.toLowerCase());
153
+ if (pendingKey) {
154
+ const miJugada = pendingRPS.get(pendingKey).miJugada;
130
155
  const suJugada = data.choice;
131
- pendingRPS.delete(data.from);
156
+ pendingRPS.delete(pendingKey);
132
157
 
133
158
  let resultStr = "😐 Es un empate.";
134
159
  let win = false;
@@ -172,7 +197,7 @@ function startChat(onExit, mode = 'udp') {
172
197
  }
173
198
 
174
199
  if (!data.text) return;
175
- if (data.type === "message" && data.to && data.to !== username) return;
200
+ if (data.type === "message" && data.to && data.to.toLowerCase() !== username.toLowerCase() && data.from.toLowerCase() !== username.toLowerCase()) return;
176
201
 
177
202
  readline.clearLine(process.stdout, 0);
178
203
  readline.cursorTo(process.stdout, 0);
@@ -257,12 +282,19 @@ function startChat(onExit, mode = 'udp') {
257
282
  }, 200);
258
283
  }
259
284
 
285
+ rl.on('SIGINT', () => {
286
+ cleanupAndExit();
287
+ });
288
+
260
289
  rl.on("line", (line) => {
261
290
  if (!username) return;
262
291
  const input = line.trim();
292
+ if (!input) { updatePrompt(); return; }
293
+
294
+ const inputLower = input.toLowerCase();
263
295
 
264
- if (input === "/exit") { cleanupAndExit(); return; }
265
- if (input === "/users") {
296
+ if (inputLower === "/exit") { cleanupAndExit(); return; }
297
+ if (inputLower === "/users") {
266
298
  console.log("──────────── 👥 USUARIOS CONECTADOS ────────────");
267
299
  const usersArr = Array.from(knownUsers);
268
300
  if (usersArr.length === 0) console.log(" 👻 No hay otros usuarios todavía.");
@@ -271,22 +303,39 @@ function startChat(onExit, mode = 'udp') {
271
303
  updatePrompt();
272
304
  return;
273
305
  }
274
- if (input === "/clear") { console.clear(); updatePrompt(); return; }
275
- if (input === "/help") {
306
+ if (inputLower === "/clear") { console.clear(); updatePrompt(); return; }
307
+ if (inputLower === "/help") {
276
308
  console.log("──────────── 💡 COMANDOS DISPONIBLES ────────────");
277
309
  console.log(" 💬 /chat <usuario> → Iniciar chat privado");
310
+ console.log(" 💬 /w <usuario> → Susurro / chat privado");
278
311
  console.log(" 🌍 /all → Volver al chat público");
279
312
  console.log(" 🪙 /flip → Lanzar una moneda");
280
313
  console.log(" 🎮 /rps <usr> <j> → Retar a Piedra/Papel/Tijera");
281
314
  console.log(" 🌟 /blink <msj> → Enviar msj parpadeante");
315
+ console.log(" 🧹 /clear → Limpiar historial de consola");
282
316
  console.log(" ❌ /exit → Salir al menú");
283
317
  updatePrompt();
284
318
  return;
285
319
  }
286
- if (input === "/all") { currentChat = ""; console.log("🌍 Chat público"); updatePrompt(); return; }
287
- if (input.startsWith("/chat ")) { currentChat = input.replace("/chat ", "").trim(); console.log(`🔒 Chat privado con ${currentChat}`); updatePrompt(); return; }
320
+ if (inputLower === "/all") { currentChat = ""; console.log("🌍 Chat público"); updatePrompt(); return; }
321
+ if (inputLower.startsWith("/chat ") || inputLower.startsWith("/w ")) {
322
+ const parts = input.split(" ");
323
+ currentChat = parts[1] || "";
324
+ if (parts.length > 2) {
325
+ const inlineMsg = parts.slice(2).join(" ").trim();
326
+ if (inlineMsg) {
327
+ broadcastData(encrypt(JSON.stringify({ type: "message", from: username, to: currentChat, text: inlineMsg, blink: false })));
328
+ readline.moveCursor(process.stdout, 0, -1);
329
+ readline.clearLine(process.stdout, 0);
330
+ console.log(`🔒 [Tú ${getUserEmoji(username)} → ${getUserEmoji(currentChat)} ${currentChat}]: ${inlineMsg}`);
331
+ updatePrompt();
332
+ return;
333
+ }
334
+ }
335
+ console.log(`🔒 Chat privado con ${currentChat}`); updatePrompt(); return;
336
+ }
288
337
 
289
- if (input === "/flip") {
338
+ if (inputLower === "/flip") {
290
339
  const choices = ["CARA", "ESCUDO"];
291
340
  const result = choices[Math.floor(Math.random() * choices.length)];
292
341
  console.log(`🪙 Lanzaste la moneda y salió: \x1b[1m\x1b[33m${result}\x1b[0m`);
@@ -295,19 +344,20 @@ function startChat(onExit, mode = 'udp') {
295
344
  return;
296
345
  }
297
346
 
298
- if (input.startsWith("/rps ")) {
347
+ if (inputLower.startsWith("/rps ")) {
299
348
  const parts = input.split(" ");
300
349
  if (parts.length < 3) { console.log("❌ Usa: /rps <usuario> <piedra|papel|tijera>"); updatePrompt(); return; }
301
350
 
302
351
  const targetUser = parts[1];
303
352
  const jugada = parts[2].toLowerCase();
304
353
  if (!["piedra", "papel", "tijera"].includes(jugada)) { console.log("❌ piedra, papel o tijera."); updatePrompt(); return; }
305
- if (targetUser === username) { console.log("❌ No puedes jugar contigo mismo."); updatePrompt(); return; }
354
+ if (targetUser.toLowerCase() === username.toLowerCase()) { console.log("❌ No puedes jugar contigo mismo."); updatePrompt(); return; }
306
355
 
307
- if (incomingRPS.has(targetUser)) {
308
- incomingRPS.delete(targetUser);
356
+ const incomingKey = Array.from(incomingRPS).find(k => k.toLowerCase() === targetUser.toLowerCase());
357
+ if (incomingKey) {
358
+ incomingRPS.delete(incomingKey);
309
359
  console.log(`🎮 Respuesta enviada (${jugada})...`);
310
- broadcastData(encrypt(JSON.stringify({ type: "rps_response", from: username, to: targetUser, choice: jugada })));
360
+ broadcastData(encrypt(JSON.stringify({ type: "rps_response", from: username, to: incomingKey, choice: jugada })));
311
361
  } else {
312
362
  pendingRPS.set(targetUser, { miJugada: jugada, time: Date.now() });
313
363
  console.log(`🎮 Retaste a ${targetUser} con ${jugada}...`);
@@ -317,7 +367,7 @@ function startChat(onExit, mode = 'udp') {
317
367
  return;
318
368
  }
319
369
 
320
- if (input.startsWith("/") && !input.startsWith("/blink ")) {
370
+ if (inputLower.startsWith("/") && !inputLower.startsWith("/blink ")) {
321
371
  console.log("❌ Comando no reconocido.");
322
372
  updatePrompt();
323
373
  return;
@@ -325,9 +375,9 @@ function startChat(onExit, mode = 'udp') {
325
375
 
326
376
  let textToSend = input;
327
377
  let isBlink = false;
328
- if (input.startsWith("/blink ")) {
378
+ if (inputLower.startsWith("/blink ")) {
329
379
  isBlink = true;
330
- textToSend = input.replace("/blink ", "").trim();
380
+ textToSend = input.substring(7).trim();
331
381
  if (!textToSend) { updatePrompt(); return; }
332
382
  } else if (!input) { updatePrompt(); return; }
333
383
 
@@ -1,305 +1,237 @@
1
- const WebSocket = require("ws");
2
-
1
+ const WebSocket = require('ws');
3
2
  const PORT = process.env.PORT || 3000;
4
3
  const wss = new WebSocket.Server({ port: PORT });
5
4
 
6
- let globalTienditaTimer = null;
7
-
8
- const sessions = new Map(); // socket -> { name: string, emoji: string }
9
- const pendingRPS = new Map(); // targetName -> { from: name, miJugada: "piedra" }
10
-
5
+ // --- ESTADO GLOBAL ---
6
+ const sessions = new Map(); // ws -> { name, emoji, privateTarget }
11
7
  const userEmojis = ["🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🦄", "🐙", "🦋", "🦖", "🐧", "🦉", "👽", "🤖", "👻", "🥑", "🍕"];
12
8
 
13
- function getTime() {
14
- const d = new Date();
15
- const hs = String(d.getHours()).padStart(2, '0');
16
- const ms = String(d.getMinutes()).padStart(2, '0');
17
- return `\x1b[90m${hs}:${ms}\x1b[0m`;
18
- }
9
+ let pendingRPS = new Map(); // target -> { from, miJugada }
19
10
 
20
- function broadcast(msg, ignoreWs = null) {
11
+ // Helpers
12
+ function broadcast(jsonObj, ignoreWs = null) {
13
+ const payload = JSON.stringify(jsonObj);
21
14
  for (const [s, data] of sessions.entries()) {
22
15
  if (s !== ignoreWs && s.readyState === WebSocket.OPEN && data.name) {
23
- try { s.send(msg); } catch (e) { }
16
+ try { s.send(payload); } catch(e) {}
24
17
  }
25
18
  }
26
19
  }
27
20
 
28
- function sendToTarget(name, msg) {
21
+ function sendToTarget(targetName, jsonObj) {
22
+ if (!targetName) return;
23
+ const payload = JSON.stringify(jsonObj);
29
24
  for (const [s, data] of sessions.entries()) {
30
- if (data.name === name && s.readyState === WebSocket.OPEN) {
31
- try { s.send(msg); } catch (e) { }
25
+ if (data.name && data.name.toLowerCase() === targetName.toLowerCase() && s.readyState === WebSocket.OPEN) {
26
+ try { s.send(payload); } catch(e) {}
32
27
  }
33
28
  }
34
29
  }
35
30
 
36
31
  function targetExists(name) {
37
- for (const data of sessions.values()) if (data.name === name) return true;
32
+ if (!name) return false;
33
+ for (const d of sessions.values()) if (d.name && d.name.toLowerCase() === name.toLowerCase()) return true;
38
34
  return false;
39
35
  }
40
36
 
41
- wss.on("connection", (server) => {
42
- sessions.set(server, { name: null, emoji: null });
43
-
44
- server.send("\x1b[36m====================================\x1b[0m\n 🚀 BIENVENIDO A LA SALA GLOBAL 🚀\n\x1b[36m====================================\x1b[0m");
45
- // 1. Al entrar por primera vez no es necesario /name
46
- server.send("🤖 Sistema: Escribe tu nombre y presiona Enter para comenzar:");
47
-
48
- server.on("message", (message) => {
49
- if (globalTienditaTimer) {
50
- clearInterval(globalTienditaTimer);
51
- globalTienditaTimer = null;
52
- }
53
-
54
- const socketData = sessions.get(server);
55
- let text = message.toString().trim();
56
-
57
- // Registro de Smart Client Silencioso
58
- if (text === "/iam_smart") {
59
- socketData.smart = true;
60
- return;
61
- }
62
-
63
- // REGISTRO ÚNICO
64
- if (!socketData.name) {
65
- if (!text || text === "/typ") return;
66
- const desiredName = text.replace("/name ", "").trim();
67
- if (targetExists(desiredName)) {
68
- server.send("❌ Nombre en uso. Escribe otro distinto:");
69
- } else {
70
- // Lógica de Emojis Únicos sin repetir (Evitamos colisiones)
71
- const takenEmojis = new Set(Array.from(sessions.values()).map(d => d.emoji).filter(Boolean));
72
- const available = userEmojis.filter(e => !takenEmojis.has(e));
73
-
74
- // Si por algun motivo la sala es masiva (> 26 usuarios), reciclamos de todos
75
- const pool = available.length > 0 ? available : userEmojis;
76
- const assignedEmoji = pool[Math.floor(Math.random() * pool.length)];
77
-
78
- socketData.name = desiredName;
79
- socketData.emoji = assignedEmoji;
80
-
81
- server.send(`✅ ¡Listo ${desiredName}! Estás conectado con el avatar ${assignedEmoji}. Escribe /help para comandos.`);
82
- broadcast(`\n[${getTime()}] 🟢 [\x1b[32m${assignedEmoji} ${desiredName}\x1b[0m] se ha unido al servidor.\n`, server);
83
- }
84
- return;
85
- }
86
-
87
- const myName = socketData.name;
88
- const myEmoji = socketData.emoji;
37
+ function getExactName(name) {
38
+ if (!name) return null;
39
+ for (const d of sessions.values()) if (d.name && d.name.toLowerCase() === name.toLowerCase()) return d.name;
40
+ return name;
41
+ }
89
42
 
90
- if (text === "/help") {
91
- server.send(`\n──────────── 💡 COMANDOS ────────────
92
- 👥 /users → Ver conectados
93
- 🪙 /flip → Lanzar moneda
94
- 🎮 /rps <usr> <j> → Piedra-Papel-Tijera
95
- 🌟 /blink <msj> → Mensaje parpadeante
96
- 🔒 /w <usr> <msj> → Mensaje privado
97
- 🏪 /tiendita Invitación a la Tiendita
98
- ℹ️ /help Esta ayuda
99
- ─────────────────────────────────────────────\n`);
100
- return;
101
- }
43
+ // State para Tiping
44
+ let typingMap = new Map();
45
+
46
+ function broadcastUsers() {
47
+ const list = [];
48
+ const plainList = [];
49
+ for(const d of sessions.values()) {
50
+ if(d.name) {
51
+ list.push(`${d.emoji} ${d.name}`);
52
+ plainList.push(d.name);
53
+ }
54
+ }
55
+ broadcast({ type: "users_update", users: list, plain: plainList });
56
+ }
102
57
 
103
- if (text === "/users") {
104
- let userList = [];
105
- for (const d of sessions.values()) if (d.name) userList.push(`${d.emoji} ${d.name}`);
106
- server.send(`\n👥 \x1b[36mCONECTADOS AHORA (${userList.length}):\x1b[0m ` + userList.join(", ") + "\n");
107
- return;
108
- }
58
+ function broadcastTyping() {
59
+ const list = Array.from(typingMap.keys());
60
+ broadcast({ type: "typing_event", users: list });
61
+ }
109
62
 
110
- // Indicadores de "Escribiendo" Exclusivos para Smart Clients
111
- if (text === "/typ" || text === "/typ_stop") {
112
- for (const [s, data] of sessions.entries()) {
113
- if (s !== server && s.readyState === WebSocket.OPEN && data.smart) {
114
- try { s.send(`${text}_event ${myEmoji} ${myName}`); } catch (e) { }
63
+ wss.on("connection", (ws) => {
64
+ sessions.set(ws, { name: null, emoji: null, privateTarget: null });
65
+
66
+ ws.on("message", (raw) => {
67
+ let req;
68
+ try { req = JSON.parse(raw); } catch(e) { return; } // Ignorar todo lo que no sea JSON moderno
69
+
70
+ const user = sessions.get(ws);
71
+
72
+ // 1. REGISTRO
73
+ if (req.type === "register") {
74
+ const desiredName = req.name.trim();
75
+ if (targetExists(desiredName)) {
76
+ ws.send(JSON.stringify({ type: "error", msg: "Nombre en uso. Elige otro." }));
77
+ return;
78
+ }
79
+ const taken = new Set(Array.from(sessions.values()).map(d => d.emoji).filter(Boolean));
80
+ const available = userEmojis.filter(e => !taken.has(e));
81
+ const emoji = available.length > 0 ? available[Math.floor(Math.random() * available.length)] : "⭐";
82
+
83
+ user.name = desiredName;
84
+ user.emoji = emoji;
85
+
86
+ ws.send(JSON.stringify({ type: "registered", name: user.name, emoji: user.emoji }));
87
+ broadcast({ type: "system", msg: `🟢 [\x1b[32m${emoji} ${user.name}\x1b[0m] se ha unido al servidor.` }, ws);
88
+ broadcastUsers();
89
+ return;
90
+ }
91
+
92
+ // Seguridad: Debe estar registrado para ejecutar comandos
93
+ if (!user.name) return;
94
+
95
+ const myName = user.name;
96
+
97
+ // Limpiar typing automático si envía un mensaje real
98
+ if (req.type === "chat" || req.type === "command") {
99
+ if (typingMap.has(myName)) {
100
+ clearTimeout(typingMap.get(myName));
101
+ typingMap.delete(myName);
102
+ broadcastTyping();
103
+ }
104
+ }
105
+
106
+ switch (req.type) {
107
+ case "typing":
108
+ if (typingMap.has(myName)) clearTimeout(typingMap.get(myName));
109
+ typingMap.set(myName, setTimeout(() => {
110
+ typingMap.delete(myName);
111
+ broadcastTyping();
112
+ }, 5000));
113
+ broadcastTyping();
114
+ break;
115
+
116
+ case "typing_stop":
117
+ if (typingMap.has(myName)) {
118
+ clearTimeout(typingMap.get(myName));
119
+ typingMap.delete(myName);
120
+ broadcastTyping();
115
121
  }
116
- }
117
- return; // Invisible, solo de control
118
- }
119
-
120
- // Restablecer chat a global
121
- if (text === "/all") {
122
- socketData.privateTarget = null;
123
- server.send(`\n[Sistema] 🟢 \x1b[32mModo Chat Público Restaurado.\x1b[0m Ahora todos leerán tus mensajes.\n`);
124
- return;
125
- }
126
-
127
- // Mensajes Privados Estáticos y Dinámicos
128
- if (text.startsWith("/w ")) {
129
- const parts = text.split(" ");
130
- const targetUser = parts[1];
131
-
132
- if (!targetExists(targetUser)) { server.send("❌ Usuario no encontrado."); return; }
133
- if (targetUser === myName) { server.send("❌ ¿Hablando contigo mismo?"); return; }
134
-
135
- // Si solo envían /w Mario (Se quedan en modo privado)
136
- if (parts.length === 2) {
137
- socketData.privateTarget = targetUser;
138
- server.send(`\n[Sistema] 🔒 \x1b[35mModo Susurro activado con ${targetUser}.\x1b[0m Todo lo que escribas solo lo leerá él. Escribe /all para salir.\n`);
139
- return;
140
- }
141
-
142
- // Si lo envían todo de golpe /w Mario hola
143
- const secretMsg = parts.slice(2).join(" ");
144
- server.send(`\n[${getTime()}] 🔒 [Private → ${targetUser}]: \x1b[35m${secretMsg}\x1b[0m`);
145
- sendToTarget(targetUser, `\n[${getTime()}] 🔒 [${myEmoji} Secreto de ${myName}]: \x1b[35m${secretMsg}\x1b[0m`);
146
- return;
147
- }
148
-
149
- // Moneda Animada ASCII Rotativa in-place
150
- if (text === "/flip") {
151
- const prep = `\n[${getTime()}] 🪙 [${myEmoji} ${myName}] lanza una moneda al aire...\n`;
152
- server.send(prep);
153
- broadcast(prep, server);
154
-
155
- const asciiCoins = [
156
- "\x1b[33m ( o ) \x1b[0m",
157
- "\x1b[33m ( | ) \x1b[0m",
158
- "\x1b[38;5;220m ( 0 ) \x1b[0m",
159
- "\x1b[33m ( | ) \x1b[0m"
160
- ];
161
-
162
- let i = 0;
163
- const timer = setInterval(() => {
164
- if (i < 15) { // 3 segundos a 200ms
165
- const frame = `\r\x1b[A\x1b[K ${asciiCoins[i % asciiCoins.length]} zumbando...`;
166
- server.send(frame);
167
- broadcast(frame, server);
168
- i++;
122
+ break;
123
+
124
+ case "chat":
125
+ const isPrivate = req.target || user.privateTarget;
126
+ const target = req.target || user.privateTarget;
127
+
128
+ if (isPrivate) {
129
+ if (!targetExists(target)) {
130
+ ws.send(JSON.stringify({ type: "error", msg: `${target} no está conectado.` }));
131
+ return;
132
+ }
133
+ const exactTarget = getExactName(target);
134
+ const pMsg = { type: "chat", from: myName, emoji: user.emoji, msg: req.msg, isWhisper: true, to: exactTarget };
135
+ ws.send(JSON.stringify(pMsg));
136
+ sendToTarget(exactTarget, pMsg);
169
137
  } else {
170
- clearInterval(timer);
171
- const result = Math.random() < 0.5 ? "CARA" : "ESCUDO";
172
- const renderMsg = `\r\x1b[A\x1b[K[${getTime()}] 🎯 ¡CAYÓ LA MONEDA de ${myName}! Es -> \x1b[1m\x1b[33m${result}\x1b[0m\n`;
173
- server.send(renderMsg);
174
- broadcast(renderMsg, server);
138
+ const globalMsg = { type: "chat", from: myName, emoji: user.emoji, msg: req.msg, isWhisper: false };
139
+ ws.send(JSON.stringify(globalMsg));
140
+ broadcast(globalMsg, ws);
141
+
142
+ // Menciones (ding!) enviamos comando JSON puro de ping a clientes
143
+ const mentions = [...req.msg.matchAll(/@([a-zA-Z0-9_]+)/g)].map(m => m[1]);
144
+ for (const m of mentions) {
145
+ if (targetExists(m) && m !== myName) {
146
+ sendToTarget(m, { type: "ding" });
147
+ }
148
+ }
175
149
  }
176
- }, 200);
177
- return;
178
- }
179
-
180
- // Comando Animado ASCII de Cambios de Colores
181
- if (text === "/tiendita") {
182
- const introMsg = `\n[${getTime()}] 🏪 \x1b[1m${myName}\x1b[0m convoca a todos a la tiendita...\n`;
183
- server.send(introMsg);
184
- broadcast(introMsg, server);
185
-
186
- const asciiArt = `_____ _ _ _ _ _ _ _
187
- |_ _(_) ___ _ __ __| (_) |_ __ _| | | |
188
- | | | |/ _ \ '_ \ / _ | | __/ _ | | | |
189
- | | | | __/ | | | (_ | | || (_ |_|_|_|
190
- |_| |_|\___|_| |_|\__,_|_|\__\__,_(_|_|_)
191
- `;
192
-
193
- const baseColors = [
194
- "\x1b[31m", // Rojo
195
- "\x1b[33m", // Amarillo
196
- "\x1b[32m", // Verde
197
- "\x1b[36m", // Cyan
198
- "\x1b[1m\x1b[35m" // Magenta Brillante
199
- ];
200
-
201
- // Primer frame puro sin código de arrastre hacia arriba
202
- const f0 = `${baseColors[0]}${asciiArt}\x1b[0m`;
203
- server.send(f0);
204
- broadcast(f0, server);
205
-
206
- let i = 1;
207
- globalTienditaTimer = setInterval(() => {
208
- const color = baseColors[i % baseColors.length];
209
- const frame = `\r\x1b[6A\x1b[0J< ${color}${asciiArt}\x1b[0m`;
210
- server.send(frame);
211
- broadcast(frame, server);
212
- i++;
213
- }, 600); // Bucle Infinito a 600ms
214
- return;
215
- }
216
-
217
- if (text.startsWith("/blink ")) {
218
- const blnkText = text.replace("/blink ", "").trim();
219
- const msg = `\n[${getTime()}] 🌟 [${myEmoji} ${myName}]: \x1b[5m${blnkText}\x1b[0m\n`;
220
- server.send(msg);
221
- broadcast(msg, server);
222
- return;
223
- }
224
-
225
- if (text.startsWith("/rps ")) {
226
- const parts = text.split(" ");
227
- if (parts.length < 3) { server.send("❌ Error: /rps <usuario> <piedra|papel|tijera>"); return; }
228
- const target = parts[1];
229
- const choice = parts[2].toLowerCase();
230
-
231
- if (!["piedra", "papel", "tijera"].includes(choice)) { server.send("❌ Elige piedra, papel o tijera."); return; }
232
- if (target === myName) { server.send("❌ No contra ti mismo."); return; }
233
- if (!targetExists(target)) { server.send(`❌ El usuario ${target} no existe.`); return; }
234
-
235
- if (pendingRPS.has(myName) && pendingRPS.get(myName).from === target) {
236
- const retoAnterior = pendingRPS.get(myName);
237
- pendingRPS.delete(myName);
238
-
239
- const miJugada = choice;
240
- const suJugada = retoAnterior.miJugada;
241
-
242
- let winStr;
243
- if (miJugada === suJugada) { winStr = "😐 Empate"; }
244
- else if ((miJugada === "piedra" && suJugada === "tijera") || (miJugada === "papel" && suJugada === "piedra") || (miJugada === "tijera" && suJugada === "papel")) { winStr = `🎉 \x1b[32m${myName}!\x1b[0m`; }
245
- else { winStr = `🎉 \x1b[32m${target}!\x1b[0m`; }
246
-
247
- const outMsg = `\n[${getTime()}] 🕹️ RESULTADO RPS 🕹️\n⚔️ \x1b[36m${target}\x1b[0m (${suJugada}) VS \x1b[36m${myName}\x1b[0m (${miJugada})\n🏆 Ganador: ${winStr}\n`;
248
- server.send(outMsg);
249
- broadcast(outMsg, server);
250
- } else {
251
- pendingRPS.set(target, { from: myName, miJugada: choice });
252
- server.send(`\n[${getTime()}] 🎮 \x1b[33mTu elección escondida (${choice}) ha sido fijada. Esperando a ${target}.\x1b[0m`);
253
- sendToTarget(target, `\n[${getTime()}] \x1b[5m\x1b[35m🚨 ¡${myName} te reta a muerte en RPS!\x1b[0m\n👉 Responde tu golpe con: /rps ${myName} <piedra|papel|tijera>\n`);
254
- }
255
- return;
256
- }
150
+ break;
257
151
 
258
- if (text.startsWith("/")) {
259
- server.send("❌ Desconocido. Usa /help");
260
- return;
261
- }
262
-
263
- // Analizar Menciones con @
264
- const formattedText = text.replace(/@([a-zA-Z0-9_]+)/g, "\x1b[1m\x1b[33m@$1\x1b[0m");
265
-
266
- // Notificar con Ring! a los mencionados (si existen)
267
- const mentions = [...text.matchAll(/@([a-zA-Z0-9_]+)/g)].map(m => m[1]);
268
- for (const m of mentions) {
269
- if (targetExists(m) && m !== myName && m !== socketData.privateTarget) {
270
- sendToTarget(m, "\x07"); // ASCII Bell character
271
- }
272
- }
273
-
274
- // Chat Normal o Modo Susurro Fijo
275
- if (socketData.privateTarget) {
276
- const target = socketData.privateTarget;
277
- if (!targetExists(target)) {
278
- server.send(`❌ ${target} ya no está conectado. Escribe /all para salir del modo privado.`);
279
- return;
280
- }
281
- server.send(`\n[${getTime()}] 🔒 [Private → ${target}]: \x1b[35m${formattedText}\x1b[0m`);
282
- sendToTarget(target, `\n[${getTime()}] 🔒 [${myEmoji} Secreto de ${myName}]: \x1b[35m${formattedText}\x1b[0m`);
283
- } else {
284
- const msg = `\n[${getTime()}] 🌍 [${myEmoji} ${myName}]: ${formattedText}`;
285
- server.send(msg);
286
- broadcast(msg, server);
287
- }
288
- });
289
-
290
- server.on("close", () => {
291
- const u = sessions.get(server);
292
- if (u && u.name) broadcast(`\n[${getTime()}] 🔴 [\x1b[31m${u.emoji} ${u.name}\x1b[0m] abandonó la sala.\n`, server);
293
- sessions.delete(server);
294
- pendingRPS.delete(u ? u.name : "");
295
- for (const [t, data] of pendingRPS.entries()) {
296
- if (data.from === u?.name) pendingRPS.delete(t);
152
+ case "command":
153
+ if (req.cmd === "users") {
154
+ const list = [];
155
+ for(const d of sessions.values()) if(d.name) list.push(`${d.emoji} ${d.name}`);
156
+ ws.send(JSON.stringify({ type: "users_list", users: list }));
157
+ }
158
+ else if (req.cmd === "flip") {
159
+ const result = Math.random() < 0.5 ? "CARA" : "ESCUDO";
160
+ // Delega el procesado nativo intensivo al cliente! Envía la órden e historial en 1 Ms.
161
+ const animData = { type: "animation", name: "flip", result: result, user: myName, emoji: user.emoji };
162
+ ws.send(JSON.stringify(animData));
163
+ broadcast(animData, ws);
164
+ }
165
+ else if (req.cmd === "tiendita") {
166
+ const animData = { type: "animation", name: "tiendita", user: myName, emoji: user.emoji };
167
+ ws.send(JSON.stringify(animData));
168
+ broadcast(animData, ws);
169
+ }
170
+ else if (req.cmd === "rps") {
171
+ const target = req.target;
172
+ const choice = req.choice;
173
+ if (!targetExists(target)) { ws.send(JSON.stringify({ type: "error", msg: "Usuario no existe" })); return; }
174
+ if (target === myName) { ws.send(JSON.stringify({ type: "error", msg: "No contigo mismo" })); return; }
175
+
176
+ if (pendingRPS.has(myName) && pendingRPS.get(myName).from === target) {
177
+ const reto = pendingRPS.get(myName);
178
+ pendingRPS.delete(myName);
179
+ let win = "😐 Empate";
180
+ if (choice !== reto.miJugada) {
181
+ if ((choice === "piedra" && reto.miJugada === "tijera") || (choice === "papel" && reto.miJugada === "piedra") || (choice === "tijera" && reto.miJugada === "papel")) win = `🎉 Ganador: \x1b[32m${myName}\x1b[0m`;
182
+ else win = `🎉 Ganador: \x1b[32m${target}\x1b[0m`;
183
+ }
184
+ const resMsg = `\n🕹️ RESULTADO RPS 🕹️\n⚔️ \x1b[36m${target}\x1b[0m (${reto.miJugada}) VS \x1b[36m${myName}\x1b[0m (${choice})\n🏆 ${win}\n`;
185
+ const resPayload = { type: "system", msg: resMsg };
186
+ ws.send(JSON.stringify(resPayload));
187
+ broadcast(resPayload, ws);
188
+ } else {
189
+ pendingRPS.set(target, { from: myName, miJugada: choice });
190
+ ws.send(JSON.stringify({ type: "system", msg: `🎮 \x1b[33mTu elección escondida (${choice}) ha sido fijada. Esperando a ${target}.\x1b[0m` }));
191
+ sendToTarget(target, { type: "system", msg: `\x1b[5m\x1b[35m🚨 ¡${myName} te reta a muerte en RPS!\x1b[0m\n👉 Responde tu golpe con: /rps ${myName} <piedra|papel|tijera>` });
192
+ }
193
+ }
194
+ else if (req.cmd === "mode_whisper") {
195
+ const exactTarget = getExactName(req.target);
196
+ if (!targetExists(exactTarget)) {
197
+ ws.send(JSON.stringify({ type: "error", msg: `${req.target} no está conectado.` }));
198
+ return;
199
+ }
200
+ user.privateTarget = exactTarget;
201
+ ws.send(JSON.stringify({ type: "system", msg: `🔒 \x1b[35mModo Privado Fijo con ${exactTarget}.\x1b[0m Usa /all para salir.` }));
202
+ }
203
+ else if (req.cmd === "mode_all") {
204
+ user.privateTarget = null;
205
+ ws.send(JSON.stringify({ type: "system", msg: `🟢 \x1b[32mModo Chat Público Restaurado.\x1b[0m` }));
206
+ }
207
+ else if (req.cmd === "blink") {
208
+ const bMsg = { type: "system", msg: `🌟 [${user.emoji} ${myName}]: \x1b[5m${req.msg}\x1b[0m` };
209
+ ws.send(JSON.stringify(bMsg));
210
+ broadcast(bMsg, ws);
211
+ }
212
+ break;
297
213
  }
298
214
  });
299
215
 
300
- server.on("error", () => {
301
- sessions.delete(server);
216
+ ws.on("close", () => {
217
+ const u = sessions.get(ws);
218
+ if (u && u.name) {
219
+ broadcast({ type: "system", msg: `🔴 [\x1b[31m${u.emoji} ${u.name}\x1b[0m] abandonó la sala.` }, ws);
220
+ if (typingMap.has(u.name)) {
221
+ clearTimeout(typingMap.get(u.name));
222
+ typingMap.delete(u.name);
223
+ broadcastTyping(); // Notificamos de inmediato al mundo para que eliminen su typing UI
224
+ }
225
+ }
226
+ sessions.delete(ws);
227
+ if (u && u.name) {
228
+ broadcastUsers();
229
+ pendingRPS.delete(u.name);
230
+ for(const [t, data] of pendingRPS.entries()) {
231
+ if(data.from === u.name) pendingRPS.delete(t);
232
+ }
233
+ }
302
234
  });
303
235
  });
304
236
 
305
- console.log(`📡 Servidor WebSocket de Node corriendo en puerto ${PORT}`);
237
+ console.log(`📡 Servidor JSON-Core Central corriendo en puerto ${PORT}`);
@@ -50,10 +50,10 @@ export default {
50
50
  text = text.trim();
51
51
 
52
52
  if (!socketData.name) {
53
- if (text.startsWith("/name ")) {
54
- const desiredName = text.replace("/name ", "").trim();
53
+ if (text.toLowerCase().startsWith("/name ")) {
54
+ const desiredName = text.substring(6).trim();
55
55
  let alreadyExists = false;
56
- for (const d of sessions.values()) if (d.name === desiredName) alreadyExists = true;
56
+ for (const d of sessions.values()) if (d.name && d.name.toLowerCase() === desiredName.toLowerCase()) alreadyExists = true;
57
57
 
58
58
  if (alreadyExists) {
59
59
  server.send("❌ Ese nombre ya está en uso. Usa /name con otro.");
@@ -70,20 +70,21 @@ export default {
70
70
 
71
71
  const myName = socketData.name;
72
72
  const myEmoji = getUserEmoji(myName);
73
+ const textLower = text.toLowerCase();
73
74
 
74
- if (text === "/help") {
75
+ if (textLower === "/help") {
75
76
  server.send(`\n──────────── 💡 COMANDOS CENTRALIZADOS ────────────\n 👥 /users → Ver conectados\n 🪙 /flip → Lanzar moneda al aire\n 🎮 /rps <usr> <j> → Piedra-Papel-Tijera\n 🌟 /blink <msj> → Mensaje parpadeante\n ℹ️ /help → Esta ayuda\n───────────────────────────────────────────────────\n`);
76
77
  return;
77
78
  }
78
79
 
79
- if (text === "/users") {
80
+ if (textLower === "/users") {
80
81
  let userList = [];
81
82
  for (const d of sessions.values()) if (d.name) userList.push(`${getUserEmoji(d.name)} ${d.name}`);
82
83
  server.send("\n👥 \x1b[36mCONECTADOS AHORA:\x1b[0m " + userList.join(", ") + "\n");
83
84
  return;
84
85
  }
85
86
 
86
- if (text === "/flip") {
87
+ if (textLower === "/flip") {
87
88
  const result = Math.random() < 0.5 ? "CARA" : "ESCUDO";
88
89
  const msg = `\n🪙 [${myEmoji} ${myName}] ha lanzado la moneda y cayó en: \x1b[1m\x1b[33m${result}\x1b[0m!\n`;
89
90
  server.send(msg); // <--- ECHO LOCAL agregado
@@ -91,24 +92,32 @@ export default {
91
92
  return;
92
93
  }
93
94
 
94
- if (text.startsWith("/blink ")) {
95
- const blnkText = text.replace("/blink ", "").trim();
95
+ if (textLower.startsWith("/blink ")) {
96
+ const blnkText = text.substring(7).trim();
96
97
  const msg = `\n🌟 [${myEmoji} ${myName}]: \x1b[5m${blnkText}\x1b[0m\n`;
97
98
  server.send(msg); // <--- ECHO LOCAL agregado
98
99
  broadcast(msg, server);
99
100
  return;
100
101
  }
101
102
 
102
- if (text.startsWith("/rps ")) {
103
+ if (textLower.startsWith("/rps ")) {
103
104
  const parts = text.split(" ");
104
105
  if (parts.length < 3) { server.send("❌ Error: /rps <usuario> <piedra|papel|tijera>"); return; }
105
106
  const target = parts[1];
106
107
  const choice = parts[2].toLowerCase();
107
108
 
109
+ let exactTargetName = target;
110
+ for (const d of sessions.values()) {
111
+ if (d.name && d.name.toLowerCase() === target.toLowerCase()) {
112
+ exactTargetName = d.name;
113
+ break;
114
+ }
115
+ }
116
+
108
117
  if (!["piedra", "papel", "tijera"].includes(choice)) { server.send("❌ Elige piedra, papel o tijera."); return; }
109
- if (target === myName) { server.send("❌ No contra ti mismo."); return; }
118
+ if (exactTargetName.toLowerCase() === myName.toLowerCase()) { server.send("❌ No contra ti mismo."); return; }
110
119
 
111
- if (pendingRPS.has(myName) && pendingRPS.get(myName).from === target) {
120
+ if (pendingRPS.has(myName) && pendingRPS.get(myName).from.toLowerCase() === exactTargetName.toLowerCase()) {
112
121
  const retoAnterior = pendingRPS.get(myName);
113
122
  pendingRPS.delete(myName);
114
123
 
@@ -118,20 +127,20 @@ export default {
118
127
  let winStr;
119
128
  if (miJugada === suJugada) { winStr = "😐 Empate"; }
120
129
  else if ( (miJugada === "piedra" && suJugada === "tijera") || (miJugada === "papel" && suJugada === "piedra") || (miJugada === "tijera" && suJugada === "papel") ) { winStr = `🎉 \x1b[32m${myName}!\x1b[0m`; }
121
- else { winStr = `🎉 \x1b[32m${target}!\x1b[0m`; }
130
+ else { winStr = `🎉 \x1b[32m${exactTargetName}!\x1b[0m`; }
122
131
 
123
- const outMsg = `\n🕹️ RESULTADO RPS 🕹️\n⚔️ \x1b[36m${target}\x1b[0m (${suJugada}) VS \x1b[36m${myName}\x1b[0m (${miJugada})\n🏆 Ganador: ${winStr}\n`;
132
+ const outMsg = `\n🕹️ RESULTADO RPS 🕹️\n⚔️ \x1b[36m${exactTargetName}\x1b[0m (${suJugada}) VS \x1b[36m${myName}\x1b[0m (${miJugada})\n🏆 Ganador: ${winStr}\n`;
124
133
  server.send(outMsg); // ECHO LOCAL
125
134
  broadcast(outMsg, server);
126
135
  } else {
127
- pendingRPS.set(target, { from: myName, miJugada: choice });
128
- server.send(`\n🎮 \x1b[33mTu reto (${choice}) ha sido enviado a ${target}.\x1b[0m`);
129
- sendToTarget(target, `\n\x1b[5m\x1b[35m🚨 ¡${myName} te reta a RPS!\x1b[0m\n👉 Responde: /rps ${myName} <piedra|papel|tijera>\n`);
136
+ pendingRPS.set(exactTargetName, { from: myName, miJugada: choice });
137
+ server.send(`\n🎮 \x1b[33mTu reto (${choice}) ha sido enviado a ${exactTargetName}.\x1b[0m`);
138
+ sendToTarget(exactTargetName, `\n\x1b[5m\x1b[35m🚨 ¡${myName} te reta a RPS!\x1b[0m\n👉 Responde: /rps ${myName} <piedra|papel|tijera>\n`);
130
139
  }
131
140
  return;
132
141
  }
133
142
 
134
- if (text.startsWith("/")) {
143
+ if (textLower.startsWith("/")) {
135
144
  server.send("❌ Comando no reconocido. Usa /help");
136
145
  return;
137
146
  }