@pdc-test/chat-io 1.0.2 → 1.1.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.
Files changed (3) hide show
  1. package/bin/cli.js +199 -88
  2. package/package.json +2 -2
  3. package/src/server/index.js +174 -273
package/bin/cli.js CHANGED
@@ -1,140 +1,251 @@
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'];
13
37
  function completer(line) {
14
38
  const hits = commands.filter((c) => c.startsWith(line));
15
39
  return [hits.length ? hits : [], line];
16
40
  }
17
41
 
18
- const rl = readline.createInterface({
19
- input: process.stdin,
20
- output: process.stdout,
21
- completer,
22
- prompt: '\r> ' // Prefix visual del input
23
- });
42
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, completer, prompt: '> ' });
24
43
 
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;
32
-
33
- // Escuchador de teclado constante (Raw Mode Mágico)
34
44
  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
- }
45
+ if (key && key.name !== 'return' && key.name !== 'enter' && myName) {
46
+ if (!currentIsTyping && ws.readyState === WebSocket.OPEN) {
47
+ currentIsTyping = true;
48
+ ws.send(JSON.stringify({ type: "typing" }));
42
49
  }
43
- // Reiniciar el timeout si el usuario sigue tecleando
44
50
  clearTimeout(typingTimeout);
45
51
  typingTimeout = setTimeout(() => {
46
- isTyping = false;
47
- if (ws.readyState === WebSocket.OPEN) ws.send("/typ_stop");
52
+ currentIsTyping = false;
53
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "typing_stop" }));
48
54
  }, 1500);
49
55
  }
50
56
  });
51
57
 
52
- // Renderizado de "Escribiendo..." en la parte SUPERIOR (En el Prompt)
58
+ // Actualizador de UI Typing Background -> Puesto a 300ms, Cero Network Latency porque es local
53
59
  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> `);
60
+ if (activeTypers.length > 0) {
61
+ const usersInfo = activeTypers.join(", ") + (activeTypers.length > 1 ? " están" : " está") + " escribiendo";
62
+ rl.setPrompt(`\x1b[90m[${usersInfo} ${typingFrames[tIdx]}]\x1b[0m\n> `);
59
63
  tIdx = (tIdx + 1) % typingFrames.length;
60
64
  rl.prompt(true);
61
65
  isMultilinePrompt = true;
62
66
  } else {
63
67
  if (isMultilinePrompt) {
64
68
  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);
69
+ clearTopLine();
69
70
  rl.prompt(true);
70
71
  isMultilinePrompt = false;
71
72
  }
72
73
  }
73
- }, 400);
74
+ }, 300);
74
75
 
75
- // Flujo WebSocket
76
+ // Flujo Principal Socket
76
77
  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.
78
+ console.log("\x1b[36m====================================\x1b[0m");
79
+ console.log(" 🚀 BIENVENIDO AL JSON-CHAT-CLI 🚀");
80
+ console.log("\x1b[36m====================================\x1b[0m");
81
+ console.log("🤖 Sistema: Escribe tu nombre y presiona Enter para comenzar:");
82
+ rl.prompt();
80
83
  });
81
84
 
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
- }
85
+ // Encargado de Pintar Todo en la Terminal limpiamente
86
+ function printMessage(msgStr) {
87
+ if (isMultilinePrompt) { clearTopLine(); isMultilinePrompt = false; }
111
88
  readline.clearLine(process.stdout, 0);
112
89
  readline.cursorTo(process.stdout, 0);
113
-
114
- console.log(msg);
90
+ console.log(msgStr);
115
91
  rl.setPrompt('> ');
116
- rl.prompt(true);
92
+ rl.prompt(true);
93
+ }
94
+
95
+ // Receptor de la Nube (Solo procesa comandos JSON Puros, nada de basura String)
96
+ ws.on('message', (data) => {
97
+ let res;
98
+ try { res = JSON.parse(data.toString()); } catch(e) { return; }
99
+
100
+ switch (res.type) {
101
+ case "error": printMessage(`❌ \x1b[31m${res.msg}\x1b[0m`); break;
102
+
103
+ case "system": printMessage(`\n[${getTime()}] ${res.msg}`); break;
104
+
105
+ case "registered":
106
+ myName = res.name;
107
+ printMessage(`\n✅ ¡Listo ${res.name}! Estás con el avatar ${res.emoji}. Escribe /help para los comandos nativos.`);
108
+ break;
109
+
110
+ case "typing_event":
111
+ // El servidor nos entrega el arreglo COMPLETO y VIVO de quién tipea
112
+ // Tu computadora solo limpia e ignora inteligentemente si está tu mismo nombre de pila
113
+ activeTypers = res.users.filter(u => u !== myName);
114
+ break;
115
+
116
+ case "ding":
117
+ process.stdout.write("\x07"); // ASCII Bell físico disparado remotamente
118
+ break;
119
+
120
+ case "users_list":
121
+ printMessage(`\n👥 \x1b[36mCONECTADOS AHORA (${res.users.length}):\x1b[0m ` + res.users.join(", ") + "\n");
122
+ break;
123
+
124
+ case "chat":
125
+ // Todo el poder de Parsear Regex se procesa en TU PC, ahorrándole billones de cálculos al Server
126
+ let safeMsg = res.msg.replace(/@([a-zA-Z0-9_]+)/g, "\x1b[1m\x1b[33m@$1\x1b[0m");
127
+ if (res.isWhisper) {
128
+ const dir = res.from === myName ? `Private → ${res.to}` : `${res.emoji} Secreto de ${res.from}`;
129
+ printMessage(`\n[${getTime()}] 🔒 [${dir}]: \x1b[35m${safeMsg}\x1b[0m`);
130
+ } else {
131
+ printMessage(`\n[${getTime()}] 🌍 [${res.emoji} ${res.from}]: ${safeMsg}`);
132
+ }
133
+ break;
134
+
135
+ case "animation":
136
+ if (res.name === "tiendita") renderTiendita(res.user);
137
+ else if (res.name === "flip") renderFlip(res.user, res.emoji, res.result);
138
+ break;
139
+ }
117
140
  });
118
141
 
119
- // Cuando le damos ENTER
142
+ // ANIMACIONES RENDERIZADAS LOCALMENTE CON CPU CLIENT (LATENCIA CERO, NO SUCIA LA RED)
143
+ function renderTiendita(user) {
144
+ if (isMultilinePrompt) { clearTopLine(); isMultilinePrompt = false; }
145
+ readline.clearLine(process.stdout, 0);
146
+ readline.cursorTo(process.stdout, 0);
147
+
148
+ console.log(`\n[${getTime()}] 🏪 \x1b[1m${user}\x1b[0m convoca a la tiendita...\n`);
149
+
150
+ const asciiArt = `_____ _ _ _ _ _ _ _ \n |_ _(_) ___ _ __ __| (_) |_ __ _| | | |\n | | | |/ _ \\ '_ \\ / _ | | __/ _ | | | |\n | | | | __/ | | | (_ | | || (_ |_|_|_|\n |_| |_|\\___|_| |_|\\__,_|_|\\__\\__,_(_|_|_)\n `;
151
+ const colors = ["\x1b[31m", "\x1b[33m", "\x1b[32m", "\x1b[36m", "\x1b[1m\x1b[35m"];
152
+
153
+ // Dejar huella Base
154
+ process.stdout.write(`${colors[0]}${asciiArt}\x1b[0m\n`);
155
+
156
+ let frames = 0;
157
+ const anim = setInterval(() => {
158
+ frames++;
159
+ const c = colors[frames % colors.length];
160
+ // Bucle mágico hiper-veloz usando RAM Local
161
+ process.stdout.write(`\r\x1b[6A\x1b[0J${c}${asciiArt}\x1b[0m\n`);
162
+ if (frames > 15) {
163
+ clearInterval(anim);
164
+ rl.prompt(true);
165
+ }
166
+ }, 150); // Mismo efecto, pero procesado x100 más eficiente
167
+ }
168
+
169
+ function renderFlip(user, emoji, result) {
170
+ if (isMultilinePrompt) { clearTopLine(); isMultilinePrompt = false; }
171
+ readline.clearLine(process.stdout, 0);
172
+ readline.cursorTo(process.stdout, 0);
173
+ console.log(`\n[${getTime()}] 🪙 [${emoji} ${user}] lanza una moneda al aire...`);
174
+
175
+ const asciiCoins = ["\x1b[33m ( o ) \x1b[0m", "\x1b[33m ( | ) \x1b[0m", "\x1b[38;5;220m ( 0 ) \x1b[0m", "\x1b[33m ( | ) \x1b[0m"];
176
+ let i = 0;
177
+
178
+ process.stdout.write(" \n");
179
+ const anim = setInterval(() => {
180
+ process.stdout.write(`\r\x1b[1A\x1b[K ${asciiCoins[i % 4]} zumbando...\n`);
181
+ i++;
182
+ if (i > 15) {
183
+ clearInterval(anim);
184
+ process.stdout.write(`\r\x1b[1A\x1b[K[${getTime()}] 🎯 ¡CAYÓ LA MONEDA de ${user}! Es -> \x1b[1m\x1b[33m${result}\x1b[0m\n\n`);
185
+ rl.prompt(true);
186
+ }
187
+ }, 100);
188
+ }
189
+
190
+ // Control General del Botón Enter (Distribuidor Estructurado)
120
191
  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
192
+ // Destruimos el "echo" por defecto para no dejar basura arriba del prompt
123
193
  readline.moveCursor(process.stdout, 0, -1);
124
194
  readline.clearLine(process.stdout, 0);
125
195
 
126
196
  const line = input.trim();
127
- if (line) {
128
- ws.send(line);
129
- isTyping = false;
130
- ws.send("/typ_stop");
197
+ if (!line) { rl.prompt(true); return; }
198
+
199
+ // Flujo Inicial Obligatorio
200
+ if (!myName) {
201
+ ws.send(JSON.stringify({ type: "register", name: line }));
202
+ return;
131
203
  }
132
- rl.prompt(true);
204
+
205
+ // Local Helper Menú
206
+ if (line === "/help") {
207
+ printMessage(`\n──────────── 💡 COMANDOS NATIVOS ────────────
208
+ 👥 /users → Ver conectados remotamente
209
+ 🪙 /flip → Lanzar moneda 60FPS
210
+ 🎮 /rps <usr> <j> → Piedra-Papel-Tijera
211
+ 🌟 /blink <msj> → Envía un texto parpadeante ANSI
212
+ 🔒 /w <usr> [msj] → Modo Susurro directo a una persona
213
+ 🌍 /all → Volver a la Sala Global Abierta
214
+ 🏪 /tiendita → Letrero de Neón de colores acelerado
215
+ ℹ️ /help → Te ayuda desde la memoria Caché
216
+ ─────────────────────────────────────────────\n`);
217
+ return;
218
+ }
219
+
220
+ // Constructor Arquitectónico y Limpio de Comandos a Servidor
221
+ if (line.startsWith("/")) {
222
+ if (line === "/users" || line === "/flip" || line === "/tiendita") {
223
+ ws.send(JSON.stringify({ type: "command", cmd: line.substring(1) }));
224
+ } else if (line === "/all") {
225
+ ws.send(JSON.stringify({ type: "command", cmd: "mode_all" }));
226
+ } else if (line.startsWith("/w ")) {
227
+ const p = line.split(" ");
228
+ if (p.length === 2) ws.send(JSON.stringify({ type: "command", cmd: "mode_whisper", target: p[1] }));
229
+ else if (p.length > 2) ws.send(JSON.stringify({ type: "chat", target: p[1], msg: p.slice(2).join(" ") }));
230
+ } else if (line.startsWith("/rps ")) {
231
+ const p = line.split(" ");
232
+ if (p.length >= 3) ws.send(JSON.stringify({ type: "command", cmd: "rps", target: p[1], choice: p[2] }));
233
+ } else if (line.startsWith("/blink ")) {
234
+ ws.send(JSON.stringify({ type: "command", cmd: "blink", msg: line.substring(7) }));
235
+ } else {
236
+ printMessage(`❌ Comando desconocido. Intenta /help`);
237
+ }
238
+ } else {
239
+ // Mensaje Casual
240
+ ws.send(JSON.stringify({ type: "chat", msg: line }));
241
+ }
242
+
243
+ isTyping = false;
244
+ ws.send(JSON.stringify({ type: "typing_stop" }));
133
245
  });
134
246
 
247
+ // Salvavidas de Desconexión Rápida
135
248
  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");
249
+ printMessage("\x1b[31m[!] Oh no... Acabas de perder la conexión con la Matriz (Nube principal caída).\x1b[0m");
139
250
  process.exit(0);
140
251
  });
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.0",
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.0",
13
13
  "crypto-js": "^4.2.0",
14
14
  "ws": "^8.0.0"
15
15
  }
@@ -1,305 +1,206 @@
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
+ const payload = JSON.stringify(jsonObj);
29
23
  for (const [s, data] of sessions.entries()) {
30
- if (data.name === name && s.readyState === WebSocket.OPEN) {
31
- try { s.send(msg); } catch (e) { }
24
+ if (data.name === targetName && s.readyState === WebSocket.OPEN) {
25
+ try { s.send(payload); } catch(e) {}
32
26
  }
33
27
  }
34
28
  }
35
29
 
36
30
  function targetExists(name) {
37
- for (const data of sessions.values()) if (data.name === name) return true;
31
+ for (const d of sessions.values()) if (d.name === name) return true;
38
32
  return false;
39
33
  }
40
34
 
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;
89
-
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
- }
102
-
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
- }
35
+ // State para Tiping
36
+ let typingMap = new Map();
37
+ function broadcastTyping() {
38
+ const list = Array.from(typingMap.keys());
39
+ broadcast({ type: "typing_event", users: list });
40
+ }
109
41
 
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) { }
42
+ wss.on("connection", (ws) => {
43
+ sessions.set(ws, { name: null, emoji: null, privateTarget: null });
44
+
45
+ ws.on("message", (raw) => {
46
+ let req;
47
+ try { req = JSON.parse(raw); } catch(e) { return; } // Ignorar todo lo que no sea JSON moderno
48
+
49
+ const user = sessions.get(ws);
50
+
51
+ // 1. REGISTRO
52
+ if (req.type === "register") {
53
+ const desiredName = req.name.trim();
54
+ if (targetExists(desiredName)) {
55
+ ws.send(JSON.stringify({ type: "error", msg: "Nombre en uso. Elige otro." }));
56
+ return;
57
+ }
58
+ const taken = new Set(Array.from(sessions.values()).map(d => d.emoji).filter(Boolean));
59
+ const available = userEmojis.filter(e => !taken.has(e));
60
+ const emoji = available.length > 0 ? available[Math.floor(Math.random() * available.length)] : "⭐";
61
+
62
+ user.name = desiredName;
63
+ user.emoji = emoji;
64
+
65
+ ws.send(JSON.stringify({ type: "registered", name: user.name, emoji: user.emoji }));
66
+ broadcast({ type: "system", msg: `🟢 [\x1b[32m${emoji} ${user.name}\x1b[0m] se ha unido al servidor.` }, ws);
67
+ return;
68
+ }
69
+
70
+ // Seguridad: Debe estar registrado para ejecutar comandos
71
+ if (!user.name) return;
72
+
73
+ const myName = user.name;
74
+
75
+ // Limpiar typing automático si envía un mensaje real
76
+ if (req.type === "chat" || req.type === "command") {
77
+ if (typingMap.has(myName)) {
78
+ clearTimeout(typingMap.get(myName));
79
+ typingMap.delete(myName);
80
+ broadcastTyping();
81
+ }
82
+ }
83
+
84
+ switch (req.type) {
85
+ case "typing":
86
+ if (typingMap.has(myName)) clearTimeout(typingMap.get(myName));
87
+ typingMap.set(myName, setTimeout(() => {
88
+ typingMap.delete(myName);
89
+ broadcastTyping();
90
+ }, 5000));
91
+ broadcastTyping();
92
+ break;
93
+
94
+ case "typing_stop":
95
+ if (typingMap.has(myName)) {
96
+ clearTimeout(typingMap.get(myName));
97
+ typingMap.delete(myName);
98
+ broadcastTyping();
115
99
  }
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++;
100
+ break;
101
+
102
+ case "chat":
103
+ const isPrivate = req.target || user.privateTarget;
104
+ const target = req.target || user.privateTarget;
105
+
106
+ if (isPrivate) {
107
+ if (!targetExists(target)) {
108
+ ws.send(JSON.stringify({ type: "error", msg: `${target} no está conectado.` }));
109
+ return;
110
+ }
111
+ const pMsg = { type: "chat", from: myName, emoji: user.emoji, msg: req.msg, isWhisper: true, to: target };
112
+ ws.send(JSON.stringify(pMsg));
113
+ sendToTarget(target, pMsg);
169
114
  } 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);
115
+ const globalMsg = { type: "chat", from: myName, emoji: user.emoji, msg: req.msg, isWhisper: false };
116
+ ws.send(JSON.stringify(globalMsg));
117
+ broadcast(globalMsg, ws);
118
+
119
+ // Menciones (ding!) enviamos comando JSON puro de ping a clientes
120
+ const mentions = [...req.msg.matchAll(/@([a-zA-Z0-9_]+)/g)].map(m => m[1]);
121
+ for (const m of mentions) {
122
+ if (targetExists(m) && m !== myName) {
123
+ sendToTarget(m, { type: "ding" });
124
+ }
125
+ }
175
126
  }
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
- }
127
+ break;
257
128
 
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);
129
+ case "command":
130
+ if (req.cmd === "users") {
131
+ const list = [];
132
+ for(const d of sessions.values()) if(d.name) list.push(`${d.emoji} ${d.name}`);
133
+ ws.send(JSON.stringify({ type: "users_list", users: list }));
134
+ }
135
+ else if (req.cmd === "flip") {
136
+ const result = Math.random() < 0.5 ? "CARA" : "ESCUDO";
137
+ // Delega el procesado nativo intensivo al cliente! Envía la órden e historial en 1 Ms.
138
+ const animData = { type: "animation", name: "flip", result: result, user: myName, emoji: user.emoji };
139
+ ws.send(JSON.stringify(animData));
140
+ broadcast(animData, ws);
141
+ }
142
+ else if (req.cmd === "tiendita") {
143
+ const animData = { type: "animation", name: "tiendita", user: myName, emoji: user.emoji };
144
+ ws.send(JSON.stringify(animData));
145
+ broadcast(animData, ws);
146
+ }
147
+ else if (req.cmd === "rps") {
148
+ const target = req.target;
149
+ const choice = req.choice;
150
+ if (!targetExists(target)) { ws.send(JSON.stringify({ type: "error", msg: "Usuario no existe" })); return; }
151
+ if (target === myName) { ws.send(JSON.stringify({ type: "error", msg: "No contigo mismo" })); return; }
152
+
153
+ if (pendingRPS.has(myName) && pendingRPS.get(myName).from === target) {
154
+ const reto = pendingRPS.get(myName);
155
+ pendingRPS.delete(myName);
156
+ let win = "😐 Empate";
157
+ if (choice !== reto.miJugada) {
158
+ if ((choice === "piedra" && reto.miJugada === "tijera") || (choice === "papel" && reto.miJugada === "piedra") || (choice === "tijera" && reto.miJugada === "papel")) win = `🎉 Ganador: \x1b[32m${myName}\x1b[0m`;
159
+ else win = `🎉 Ganador: \x1b[32m${target}\x1b[0m`;
160
+ }
161
+ const resMsg = `\n🕹️ RESULTADO RPS 🕹️\n⚔️ \x1b[36m${target}\x1b[0m (${reto.miJugada}) VS \x1b[36m${myName}\x1b[0m (${choice})\n🏆 ${win}\n`;
162
+ const resPayload = { type: "system", msg: resMsg };
163
+ ws.send(JSON.stringify(resPayload));
164
+ broadcast(resPayload, ws);
165
+ } else {
166
+ pendingRPS.set(target, { from: myName, miJugada: choice });
167
+ ws.send(JSON.stringify({ type: "system", msg: `🎮 \x1b[33mTu elección escondida (${choice}) ha sido fijada. Esperando a ${target}.\x1b[0m` }));
168
+ 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>` });
169
+ }
170
+ }
171
+ else if (req.cmd === "mode_whisper") {
172
+ user.privateTarget = req.target;
173
+ ws.send(JSON.stringify({ type: "system", msg: `🔒 \x1b[35mModo Privado Fijo con ${req.target}.\x1b[0m Usa /all para salir.` }));
174
+ }
175
+ else if (req.cmd === "mode_all") {
176
+ user.privateTarget = null;
177
+ ws.send(JSON.stringify({ type: "system", msg: `🟢 \x1b[32mModo Chat Público Restaurado.\x1b[0m` }));
178
+ }
179
+ else if (req.cmd === "blink") {
180
+ const bMsg = { type: "system", msg: `🌟 [${user.emoji} ${myName}]: \x1b[5m${req.msg}\x1b[0m` };
181
+ ws.send(JSON.stringify(bMsg));
182
+ broadcast(bMsg, ws);
183
+ }
184
+ break;
297
185
  }
298
186
  });
299
187
 
300
- server.on("error", () => {
301
- sessions.delete(server);
188
+ ws.on("close", () => {
189
+ const u = sessions.get(ws);
190
+ if (u && u.name) {
191
+ broadcast({ type: "system", msg: `🔴 [\x1b[31m${u.emoji} ${u.name}\x1b[0m] abandonó la sala.` }, ws);
192
+ if (typingMap.has(u.name)) {
193
+ clearTimeout(typingMap.get(u.name));
194
+ typingMap.delete(u.name);
195
+ broadcastTyping(); // Notificamos de inmediato al mundo para que eliminen su typing UI
196
+ }
197
+ }
198
+ sessions.delete(ws);
199
+ pendingRPS.delete(u ? u.name : "");
200
+ for(const [t, data] of pendingRPS.entries()) {
201
+ if(data.from === u?.name) pendingRPS.delete(t);
202
+ }
302
203
  });
303
204
  });
304
205
 
305
- console.log(`📡 Servidor WebSocket de Node corriendo en puerto ${PORT}`);
206
+ console.log(`📡 Servidor JSON-Core Central corriendo en puerto ${PORT}`);