getpicked 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +655 -0
- package/package.json +17 -0
package/index.js
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import blessed from "blessed";
|
|
4
|
+
import WebSocket from "ws";
|
|
5
|
+
|
|
6
|
+
const isLocal = process.argv.includes("--local");
|
|
7
|
+
const SERVER_URL = process.env.PORTO_SERVER ||
|
|
8
|
+
(isLocal ? "ws://localhost:8111/ws" : "wss://shapely-insect.spcf.app/ws");
|
|
9
|
+
|
|
10
|
+
// ─── Colors ──────────────────────────────────────────────────────
|
|
11
|
+
const C = {
|
|
12
|
+
bg: "#0a0a0f",
|
|
13
|
+
panel: "#111118",
|
|
14
|
+
border: "#2a2a3a",
|
|
15
|
+
dim: "#444466",
|
|
16
|
+
text: "#8888aa",
|
|
17
|
+
bright: "#bbbbdd",
|
|
18
|
+
white: "#ffffff",
|
|
19
|
+
green: "#22cc66",
|
|
20
|
+
red: "#ee4444",
|
|
21
|
+
yellow: "#eebb33",
|
|
22
|
+
cyan: "#44dddd",
|
|
23
|
+
purple: "#aa66ff",
|
|
24
|
+
orange: "#ee8833",
|
|
25
|
+
dark: "#181820",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ─── Screen ──────────────────────────────────────────────────────
|
|
29
|
+
const screen = blessed.screen({
|
|
30
|
+
smartCSR: true,
|
|
31
|
+
title: "Porto",
|
|
32
|
+
fullUnicode: true,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Track scene-specific key handlers so we can cleanly remove them
|
|
36
|
+
let sceneKeyHandlers = [];
|
|
37
|
+
|
|
38
|
+
function onKey(keys, fn) {
|
|
39
|
+
screen.key(keys, fn);
|
|
40
|
+
sceneKeyHandlers.push({ keys, fn });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clearSceneKeys() {
|
|
44
|
+
for (const handler of sceneKeyHandlers) {
|
|
45
|
+
if (handler._keypressHandler) {
|
|
46
|
+
screen.removeListener("keypress", handler._keypressHandler);
|
|
47
|
+
} else {
|
|
48
|
+
screen.unkey(handler.keys, handler.fn);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
sceneKeyHandlers = [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function clearScreen() {
|
|
55
|
+
clearSceneKeys();
|
|
56
|
+
// Detach all children
|
|
57
|
+
while (screen.children.length) {
|
|
58
|
+
screen.children[0].detach();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Global exit
|
|
63
|
+
screen.key(["C-c"], () => process.exit(0));
|
|
64
|
+
|
|
65
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
66
|
+
// MAIN MENU
|
|
67
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
68
|
+
|
|
69
|
+
let username = "";
|
|
70
|
+
|
|
71
|
+
function showMainMenu() {
|
|
72
|
+
clearScreen();
|
|
73
|
+
|
|
74
|
+
const container = blessed.box({
|
|
75
|
+
parent: screen,
|
|
76
|
+
top: 0,
|
|
77
|
+
left: 0,
|
|
78
|
+
width: "100%",
|
|
79
|
+
height: "100%",
|
|
80
|
+
style: { bg: C.bg },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ASCII logo
|
|
84
|
+
const logoLines = [
|
|
85
|
+
"██████╗ ██████╗ ██████╗ ████████╗ ██████╗ ",
|
|
86
|
+
"██╔══██╗██╔═══██╗██╔══██╗╚══██╔══╝██╔═══██╗",
|
|
87
|
+
"██████╔╝██║ ██║██████╔╝ ██║ ██║ ██║",
|
|
88
|
+
"██╔═══╝ ██║ ██║██╔══██╗ ██║ ██║ ██║",
|
|
89
|
+
"██║ ╚██████╔╝██║ ██║ ██║ ╚██████╔╝",
|
|
90
|
+
"╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝",
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
blessed.box({
|
|
94
|
+
parent: container,
|
|
95
|
+
top: Math.max(2, Math.floor(screen.height / 2) - 10),
|
|
96
|
+
left: "center",
|
|
97
|
+
width: 48,
|
|
98
|
+
height: logoLines.length,
|
|
99
|
+
content: logoLines.join("\n"),
|
|
100
|
+
tags: true,
|
|
101
|
+
style: { fg: C.purple, bg: C.bg },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Subtitle
|
|
105
|
+
blessed.box({
|
|
106
|
+
parent: container,
|
|
107
|
+
top: Math.max(2, Math.floor(screen.height / 2) - 10) + logoLines.length + 1,
|
|
108
|
+
left: "center",
|
|
109
|
+
width: 30,
|
|
110
|
+
height: 1,
|
|
111
|
+
content: "{center}the raffle game{/center}",
|
|
112
|
+
tags: true,
|
|
113
|
+
style: { fg: C.dim, bg: C.bg },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Menu
|
|
117
|
+
const menuItems = [
|
|
118
|
+
{
|
|
119
|
+
label: username ? `Change name` : "Set username",
|
|
120
|
+
hint: username ? ` (${username})` : "",
|
|
121
|
+
action: () => showUsernameInput(),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
label: "Join lobby",
|
|
125
|
+
hint: "",
|
|
126
|
+
disabled: !username,
|
|
127
|
+
action: () => showLobby(),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
label: "Quit",
|
|
131
|
+
hint: "",
|
|
132
|
+
action: () => process.exit(0),
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
let sel = username ? 1 : 0;
|
|
137
|
+
|
|
138
|
+
const menuTop = Math.max(2, Math.floor(screen.height / 2) - 10) + logoLines.length + 4;
|
|
139
|
+
|
|
140
|
+
const menuBox = blessed.box({
|
|
141
|
+
parent: container,
|
|
142
|
+
top: menuTop,
|
|
143
|
+
left: "center",
|
|
144
|
+
width: 40,
|
|
145
|
+
height: menuItems.length * 2 + 2,
|
|
146
|
+
tags: true,
|
|
147
|
+
style: { bg: C.bg },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
function renderMenu() {
|
|
151
|
+
let out = "";
|
|
152
|
+
menuItems.forEach((item, i) => {
|
|
153
|
+
const selected = i === sel;
|
|
154
|
+
const disabled = item.disabled;
|
|
155
|
+
|
|
156
|
+
if (selected && !disabled) {
|
|
157
|
+
out += ` {${C.cyan}-fg}{bold} ▸ ${item.label}{/bold}{/}`;
|
|
158
|
+
if (item.hint) out += `{${C.dim}-fg}${item.hint}{/}`;
|
|
159
|
+
out += "\n\n";
|
|
160
|
+
} else if (disabled) {
|
|
161
|
+
out += ` {${C.dim}-fg} ${item.label} {/}`;
|
|
162
|
+
if (selected) out += `{${C.dim}-fg}(set name first){/}`;
|
|
163
|
+
out += "\n\n";
|
|
164
|
+
} else {
|
|
165
|
+
out += ` {${C.text}-fg} ${item.label}{/}`;
|
|
166
|
+
if (item.hint) out += `{${C.dim}-fg}${item.hint}{/}`;
|
|
167
|
+
out += "\n\n";
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
menuBox.setContent(out);
|
|
171
|
+
screen.render();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
onKey(["up", "k"], () => {
|
|
175
|
+
sel = (sel - 1 + menuItems.length) % menuItems.length;
|
|
176
|
+
renderMenu();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
onKey(["down", "j"], () => {
|
|
180
|
+
sel = (sel + 1) % menuItems.length;
|
|
181
|
+
renderMenu();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
onKey(["enter", "return"], () => {
|
|
185
|
+
const item = menuItems[sel];
|
|
186
|
+
if (item.disabled) return;
|
|
187
|
+
item.action();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Keyboard hints at bottom
|
|
191
|
+
blessed.box({
|
|
192
|
+
parent: container,
|
|
193
|
+
bottom: 1,
|
|
194
|
+
left: "center",
|
|
195
|
+
width: 50,
|
|
196
|
+
height: 1,
|
|
197
|
+
content: "{center}{" + C.dim + "-fg}↑↓ navigate · enter select · ctrl-c quit{/}{/center}",
|
|
198
|
+
tags: true,
|
|
199
|
+
style: { bg: C.bg },
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
renderMenu();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
206
|
+
// USERNAME INPUT
|
|
207
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
208
|
+
|
|
209
|
+
function showUsernameInput(thenJoin = false) {
|
|
210
|
+
clearScreen();
|
|
211
|
+
|
|
212
|
+
const container = blessed.box({
|
|
213
|
+
parent: screen,
|
|
214
|
+
top: 0,
|
|
215
|
+
left: 0,
|
|
216
|
+
width: "100%",
|
|
217
|
+
height: "100%",
|
|
218
|
+
style: { bg: C.bg },
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const dialog = blessed.box({
|
|
222
|
+
parent: container,
|
|
223
|
+
top: "center",
|
|
224
|
+
left: "center",
|
|
225
|
+
width: 50,
|
|
226
|
+
height: 13,
|
|
227
|
+
border: { type: "line" },
|
|
228
|
+
style: {
|
|
229
|
+
bg: C.panel,
|
|
230
|
+
border: { fg: C.purple },
|
|
231
|
+
},
|
|
232
|
+
tags: true,
|
|
233
|
+
shadow: true,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
blessed.text({
|
|
237
|
+
parent: dialog,
|
|
238
|
+
top: 1,
|
|
239
|
+
left: "center",
|
|
240
|
+
content: "CHOOSE YOUR NAME",
|
|
241
|
+
style: { fg: C.bright, bg: C.panel, bold: true },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
blessed.line({
|
|
245
|
+
parent: dialog,
|
|
246
|
+
top: 3,
|
|
247
|
+
left: 1,
|
|
248
|
+
right: 1,
|
|
249
|
+
width: "100%-4",
|
|
250
|
+
orientation: "horizontal",
|
|
251
|
+
style: { fg: C.border },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const inputBox = blessed.box({
|
|
255
|
+
parent: dialog,
|
|
256
|
+
top: 5,
|
|
257
|
+
left: 4,
|
|
258
|
+
right: 4,
|
|
259
|
+
height: 1,
|
|
260
|
+
tags: false,
|
|
261
|
+
style: { bg: C.dark },
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const cursorEl = blessed.box({
|
|
265
|
+
parent: dialog,
|
|
266
|
+
top: 5,
|
|
267
|
+
left: 4,
|
|
268
|
+
width: 1,
|
|
269
|
+
height: 1,
|
|
270
|
+
style: { fg: 0, bg: 15 },
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
let inputValue = username;
|
|
274
|
+
let cursorPos = inputValue.length;
|
|
275
|
+
|
|
276
|
+
function renderInput() {
|
|
277
|
+
// Raw ANSI bright white + bold for the input text
|
|
278
|
+
inputBox.setContent(`\x1b[38;5;231;1m${inputValue}\x1b[0m`);
|
|
279
|
+
cursorEl.left = 4 + cursorPos;
|
|
280
|
+
cursorEl.setContent(inputValue[cursorPos] || " ");
|
|
281
|
+
screen.render();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
blessed.text({
|
|
285
|
+
parent: dialog,
|
|
286
|
+
top: 8,
|
|
287
|
+
left: "center",
|
|
288
|
+
content: "Enter confirm · Esc cancel",
|
|
289
|
+
style: { fg: C.dim, bg: C.panel },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const errText = blessed.text({
|
|
293
|
+
parent: dialog,
|
|
294
|
+
top: 10,
|
|
295
|
+
left: "center",
|
|
296
|
+
content: "",
|
|
297
|
+
style: { fg: C.red, bg: C.panel },
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
function submit() {
|
|
301
|
+
const trimmed = inputValue.trim();
|
|
302
|
+
if (!trimmed) {
|
|
303
|
+
errText.setContent("Name cannot be empty");
|
|
304
|
+
screen.render();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
username = trimmed.slice(0, 20);
|
|
308
|
+
if (thenJoin) showLobby();
|
|
309
|
+
else showMainMenu();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function cancel() {
|
|
313
|
+
if (thenJoin && !username) showMainMenu();
|
|
314
|
+
else showMainMenu();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Single keypress handler for all input — avoids conflicts with screen.key()
|
|
318
|
+
function onKeypress(ch, key) {
|
|
319
|
+
if (!key) return;
|
|
320
|
+
|
|
321
|
+
if (key.name === "return" || key.name === "enter") {
|
|
322
|
+
submit();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (key.name === "escape") {
|
|
326
|
+
cancel();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (key.name === "backspace") {
|
|
330
|
+
if (cursorPos > 0) {
|
|
331
|
+
inputValue = inputValue.slice(0, cursorPos - 1) + inputValue.slice(cursorPos);
|
|
332
|
+
cursorPos--;
|
|
333
|
+
}
|
|
334
|
+
} else if (key.name === "delete") {
|
|
335
|
+
inputValue = inputValue.slice(0, cursorPos) + inputValue.slice(cursorPos + 1);
|
|
336
|
+
} else if (key.name === "left") {
|
|
337
|
+
if (cursorPos > 0) cursorPos--;
|
|
338
|
+
} else if (key.name === "right") {
|
|
339
|
+
if (cursorPos < inputValue.length) cursorPos++;
|
|
340
|
+
} else if (key.name === "home") {
|
|
341
|
+
cursorPos = 0;
|
|
342
|
+
} else if (key.name === "end") {
|
|
343
|
+
cursorPos = inputValue.length;
|
|
344
|
+
} else if (ch && !key.ctrl && !key.meta && ch.length === 1 && inputValue.length < 20) {
|
|
345
|
+
inputValue = inputValue.slice(0, cursorPos) + ch + inputValue.slice(cursorPos);
|
|
346
|
+
cursorPos++;
|
|
347
|
+
} else {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
errText.setContent("");
|
|
352
|
+
renderInput();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Defer registration so the Enter key from the menu doesn't leak through
|
|
356
|
+
process.nextTick(() => {
|
|
357
|
+
screen.on("keypress", onKeypress);
|
|
358
|
+
});
|
|
359
|
+
sceneKeyHandlers.push({ _keypressHandler: onKeypress });
|
|
360
|
+
|
|
361
|
+
renderInput();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
365
|
+
// LOBBY
|
|
366
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
367
|
+
|
|
368
|
+
function showLobby() {
|
|
369
|
+
clearScreen();
|
|
370
|
+
|
|
371
|
+
if (!username) {
|
|
372
|
+
return showUsernameInput(true);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const container = blessed.box({
|
|
376
|
+
parent: screen,
|
|
377
|
+
top: 0,
|
|
378
|
+
left: 0,
|
|
379
|
+
width: "100%",
|
|
380
|
+
height: "100%",
|
|
381
|
+
style: { bg: C.bg },
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// ── Header ──
|
|
385
|
+
const headerBox = blessed.box({
|
|
386
|
+
parent: container,
|
|
387
|
+
top: 0,
|
|
388
|
+
left: 0,
|
|
389
|
+
width: "100%",
|
|
390
|
+
height: 3,
|
|
391
|
+
style: { bg: C.panel },
|
|
392
|
+
tags: true,
|
|
393
|
+
padding: { left: 1, right: 1 },
|
|
394
|
+
valign: "middle",
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ── Progress bar ──
|
|
398
|
+
const progressBox = blessed.box({
|
|
399
|
+
parent: container,
|
|
400
|
+
top: 3,
|
|
401
|
+
left: 0,
|
|
402
|
+
width: "100%",
|
|
403
|
+
height: 1,
|
|
404
|
+
style: { bg: C.bg },
|
|
405
|
+
tags: true,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ── Player grid ──
|
|
409
|
+
const gridBox = blessed.box({
|
|
410
|
+
parent: container,
|
|
411
|
+
top: 4,
|
|
412
|
+
left: 0,
|
|
413
|
+
width: "100%",
|
|
414
|
+
height: "100%-7",
|
|
415
|
+
tags: true,
|
|
416
|
+
style: { bg: C.bg },
|
|
417
|
+
scrollable: true,
|
|
418
|
+
alwaysScroll: true,
|
|
419
|
+
mouse: true,
|
|
420
|
+
scrollbar: { style: { bg: C.dim } },
|
|
421
|
+
padding: { left: 2, right: 2, top: 1, bottom: 1 },
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ── Footer ──
|
|
425
|
+
const footerBox = blessed.box({
|
|
426
|
+
parent: container,
|
|
427
|
+
bottom: 0,
|
|
428
|
+
left: 0,
|
|
429
|
+
width: "100%",
|
|
430
|
+
height: 3,
|
|
431
|
+
style: { bg: C.panel },
|
|
432
|
+
tags: true,
|
|
433
|
+
padding: { left: 2, right: 2 },
|
|
434
|
+
valign: "middle",
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// ── State ──
|
|
438
|
+
let ws = null;
|
|
439
|
+
let lobbyState = null;
|
|
440
|
+
let stateReceivedAt = null;
|
|
441
|
+
let myId = null;
|
|
442
|
+
let pingInterval = null;
|
|
443
|
+
let renderInterval = null;
|
|
444
|
+
let dead = false;
|
|
445
|
+
|
|
446
|
+
function leave() {
|
|
447
|
+
clearInterval(pingInterval);
|
|
448
|
+
clearInterval(renderInterval);
|
|
449
|
+
if (ws) {
|
|
450
|
+
try { ws.close(); } catch {}
|
|
451
|
+
}
|
|
452
|
+
showMainMenu();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
onKey(["q", "escape"], leave);
|
|
456
|
+
|
|
457
|
+
// ── Connect ──
|
|
458
|
+
headerBox.setContent(`{center}{${C.yellow}-fg}⟳ Connecting...{/}{/center}`);
|
|
459
|
+
screen.render();
|
|
460
|
+
|
|
461
|
+
const wsUrl = `${SERVER_URL}?username=${encodeURIComponent(username)}`;
|
|
462
|
+
ws = new WebSocket(wsUrl);
|
|
463
|
+
|
|
464
|
+
ws.on("open", () => {
|
|
465
|
+
pingInterval = setInterval(() => {
|
|
466
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
467
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
468
|
+
}
|
|
469
|
+
}, 5000);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
ws.on("message", (raw) => {
|
|
473
|
+
const msg = JSON.parse(raw.toString());
|
|
474
|
+
if (msg.type === "lobby_state") {
|
|
475
|
+
if (msg.your_id) myId = msg.your_id;
|
|
476
|
+
lobbyState = msg;
|
|
477
|
+
stateReceivedAt = Date.now();
|
|
478
|
+
render();
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
ws.on("close", () => {
|
|
483
|
+
clearInterval(pingInterval);
|
|
484
|
+
dead = true;
|
|
485
|
+
headerBox.setContent(`{center}{${C.red}-fg}● Disconnected{/}{/center}`);
|
|
486
|
+
footerBox.setContent(`{center}{${C.dim}-fg}Press Q or Esc to return{/}{/center}`);
|
|
487
|
+
screen.render();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
ws.on("error", () => {
|
|
491
|
+
clearInterval(pingInterval);
|
|
492
|
+
dead = true;
|
|
493
|
+
headerBox.setContent(`{center}{${C.red}-fg}● Connection failed — is the server running?{/}{/center}`);
|
|
494
|
+
footerBox.setContent(`{center}{${C.dim}-fg}Press Q or Esc to return{/}{/center}`);
|
|
495
|
+
screen.render();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// Timer refresh
|
|
499
|
+
renderInterval = setInterval(() => {
|
|
500
|
+
if (lobbyState && !dead) render();
|
|
501
|
+
}, 200);
|
|
502
|
+
|
|
503
|
+
// 256-color index 231 = true #ffffff white, NOT remappable by terminal themes
|
|
504
|
+
// (palette indices 0-15 like SGR 97 are theme-dependent and render as grey)
|
|
505
|
+
const W = "\x1b[38;5;231m";
|
|
506
|
+
const B = "\x1b[1m";
|
|
507
|
+
const R = "\x1b[0m";
|
|
508
|
+
|
|
509
|
+
function render() {
|
|
510
|
+
if (!lobbyState) return;
|
|
511
|
+
|
|
512
|
+
const { state, players, player_count, max_players, time_remaining, countdown_remaining, winner_id } = lobbyState;
|
|
513
|
+
|
|
514
|
+
const elapsed = (Date.now() - stateReceivedAt) / 1000;
|
|
515
|
+
|
|
516
|
+
// ── Header ── uses {|} for left/right split
|
|
517
|
+
if (state === "waiting") {
|
|
518
|
+
const t = Math.max(0, Math.ceil(time_remaining - elapsed));
|
|
519
|
+
const lid = lobbyState.lobby_id;
|
|
520
|
+
headerBox.setContent(
|
|
521
|
+
`{${C.green}-fg}● LOBBY{/} {${C.dim}-fg}#${lid}{/}{|}${W}${player_count}${R}{${C.dim}-fg}/${max_players}{/} {${C.yellow}-fg}⏱ ${t}s{/}`
|
|
522
|
+
);
|
|
523
|
+
const barW = screen.width;
|
|
524
|
+
const filled = Math.round((player_count / max_players) * barW);
|
|
525
|
+
progressBox.setContent(
|
|
526
|
+
`{${C.green}-fg}${"▀".repeat(Math.min(filled, barW))}{/}` +
|
|
527
|
+
`{${C.dark}-fg}${"▀".repeat(Math.max(0, barW - filled))}{/}`
|
|
528
|
+
);
|
|
529
|
+
} else if (state === "countdown") {
|
|
530
|
+
const t = Math.max(0, Math.ceil(countdown_remaining - elapsed));
|
|
531
|
+
const pips = Array.from({ length: 10 }, (_, i) =>
|
|
532
|
+
i < t ? `{${C.orange}-fg}◆{/}` : `{${C.dim}-fg}◇{/}`
|
|
533
|
+
).join("");
|
|
534
|
+
headerBox.setContent(
|
|
535
|
+
`{center}{${C.orange}-fg}{bold}✦ RAFFLE ✦{/bold} ${pips} {${C.yellow}-fg}${t}s{/}{/center}`
|
|
536
|
+
);
|
|
537
|
+
progressBox.setContent(`{${C.orange}-fg}${"▀".repeat(screen.width)}{/}`);
|
|
538
|
+
} else if (state === "reveal") {
|
|
539
|
+
headerBox.setContent(
|
|
540
|
+
`{center}{${C.green}-fg}{bold}✦ WINNER REVEALED ✦{/bold}{/}{/center}`
|
|
541
|
+
);
|
|
542
|
+
progressBox.setContent(`{${C.green}-fg}${"▀".repeat(screen.width)}{/}`);
|
|
543
|
+
} else {
|
|
544
|
+
headerBox.setContent(
|
|
545
|
+
`{center}{${C.green}-fg}{bold}✦ RAFFLE COMPLETE ✦{/bold}{/}{/center}`
|
|
546
|
+
);
|
|
547
|
+
progressBox.setContent(`{${C.green}-fg}${"▀".repeat(screen.width)}{/}`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── Grid ── uses raw ANSI for white names
|
|
551
|
+
const colW = 26;
|
|
552
|
+
const availW = gridBox.width - 4;
|
|
553
|
+
const cols = Math.max(1, Math.floor(availW / colW));
|
|
554
|
+
const lines = [];
|
|
555
|
+
let row = "";
|
|
556
|
+
let c = 0;
|
|
557
|
+
|
|
558
|
+
for (const player of players) {
|
|
559
|
+
let dot, nameAnsi;
|
|
560
|
+
|
|
561
|
+
if (state === "waiting") {
|
|
562
|
+
if (player.status === "online") {
|
|
563
|
+
dot = `{${C.green}-fg}●{/}`;
|
|
564
|
+
nameAnsi = player.id === myId ? `${W}${B}` : `${W}`;
|
|
565
|
+
} else {
|
|
566
|
+
dot = `{${C.dim}-fg}●{/}`;
|
|
567
|
+
nameAnsi = `{${C.dim}-fg}`;
|
|
568
|
+
}
|
|
569
|
+
} else if (state === "countdown") {
|
|
570
|
+
// All dots dark during countdown — suspense, no winner known
|
|
571
|
+
dot = `{${C.dark}-fg}●{/}`;
|
|
572
|
+
nameAnsi = `{${C.dark}-fg}`;
|
|
573
|
+
} else if (state === "reveal") {
|
|
574
|
+
if (player.id === winner_id) {
|
|
575
|
+
dot = `${W}●${R}`;
|
|
576
|
+
nameAnsi = `${W}${B}`;
|
|
577
|
+
} else {
|
|
578
|
+
dot = `{${C.dark}-fg}●{/}`;
|
|
579
|
+
nameAnsi = `{${C.dark}-fg}`;
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
582
|
+
if (player.id === winner_id) {
|
|
583
|
+
dot = `{${C.green}-fg}★{/}`;
|
|
584
|
+
nameAnsi = `{${C.green}-fg}{bold}`;
|
|
585
|
+
} else {
|
|
586
|
+
dot = `{${C.dark}-fg}●{/}`;
|
|
587
|
+
nameAnsi = `{${C.dark}-fg}`;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const name = player.username.length > 20
|
|
592
|
+
? player.username.slice(0, 19) + "…"
|
|
593
|
+
: player.username;
|
|
594
|
+
|
|
595
|
+
const pad = " ".repeat(Math.max(1, 22 - name.length));
|
|
596
|
+
// Use {/} reset for tag-based styles, \x1b[0m for ANSI-based
|
|
597
|
+
const resetStr = nameAnsi.startsWith("\x1b") ? R : "{/}";
|
|
598
|
+
row += ` ${dot} ${nameAnsi}${name}${resetStr}${pad}`;
|
|
599
|
+
c++;
|
|
600
|
+
|
|
601
|
+
if (c >= cols) {
|
|
602
|
+
lines.push(row);
|
|
603
|
+
row = "";
|
|
604
|
+
c = 0;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (row) lines.push(row);
|
|
608
|
+
|
|
609
|
+
if (players.length === 0) {
|
|
610
|
+
lines.push(` {${C.dim}-fg}Waiting for the first player...{/}`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
gridBox.setContent(lines.join("\n\n"));
|
|
614
|
+
|
|
615
|
+
// ── Footer ── uses {|} for left/right split
|
|
616
|
+
if (state === "waiting") {
|
|
617
|
+
footerBox.setContent(
|
|
618
|
+
`{${C.text}-fg}You: ${W}${B}${username}${R}{|}{${C.dim}-fg}Q{/} {${C.text}-fg}leave lobby{/}`
|
|
619
|
+
);
|
|
620
|
+
} else if (state === "countdown") {
|
|
621
|
+
footerBox.setContent(
|
|
622
|
+
`{center}{${C.orange}-fg}{bold}✦ Drawing winner...{/bold}{/}{/center}`
|
|
623
|
+
);
|
|
624
|
+
} else if (state === "reveal" || state === "finished") {
|
|
625
|
+
const winner = players.find((p) => p.id === winner_id);
|
|
626
|
+
const wName = winner ? winner.username : "???";
|
|
627
|
+
const isMe = winner_id === myId;
|
|
628
|
+
|
|
629
|
+
if (state === "reveal") {
|
|
630
|
+
if (isMe) {
|
|
631
|
+
footerBox.setContent(`{center}{${C.green}-fg}{bold}★ YOU HAVE BEEN CHOSEN ★{/bold}{/}{/center}`);
|
|
632
|
+
} else {
|
|
633
|
+
footerBox.setContent(`{center}{${C.orange}-fg}Winner: {bold}${wName}{/bold}{/}{/center}`);
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
if (isMe) {
|
|
637
|
+
footerBox.setContent(
|
|
638
|
+
`{center}{${C.green}-fg}{bold}★ WINNER: YOU ★{/bold}{/} ` +
|
|
639
|
+
`{${C.dim}-fg}Q to return{/}{/center}`
|
|
640
|
+
);
|
|
641
|
+
} else {
|
|
642
|
+
footerBox.setContent(
|
|
643
|
+
`{center}${W}Winner: ${B}${wName}${R} ` +
|
|
644
|
+
`{${C.dim}-fg}Q to return{/}{/center}`
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
screen.render();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ─── Go ──────────────────────────────────────────────────────────
|
|
655
|
+
showMainMenu();
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "getpicked",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Join a lobby, enter the raffle, win the draw",
|
|
5
|
+
"bin": {
|
|
6
|
+
"getpicked": "./index.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"keywords": ["raffle", "game", "multiplayer", "terminal", "cli"],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"files": ["index.js"],
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"blessed": "^0.1.81",
|
|
14
|
+
"blessed-contrib": "^4.11.0",
|
|
15
|
+
"ws": "^8.16.0"
|
|
16
|
+
}
|
|
17
|
+
}
|