so-called-expert 0.0.0-stage → 0.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.
- package/README.md +76 -2
- package/bin/cli.js +139 -0
- package/package.json +32 -4
- package/src/ai.js +151 -0
- package/src/client.js +175 -0
- package/src/game.js +376 -0
- package/src/server.js +110 -0
- package/src/text.js +83 -0
- package/src/tunnel.js +50 -0
- package/src/ui.js +303 -0
package/src/game.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// The authoritative game state machine. It knows nothing about sockets or
|
|
2
|
+
// terminals: the server feeds it messages and broadcasts `viewFor(player)`.
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { normalize, similar, clean } from './text.js';
|
|
5
|
+
|
|
6
|
+
export const DEFAULTS = {
|
|
7
|
+
questionsPerSubject: 5,
|
|
8
|
+
pickMs: 90_000,
|
|
9
|
+
introMs: 12_000,
|
|
10
|
+
questionMs: 30_000,
|
|
11
|
+
revealMs: 12_000,
|
|
12
|
+
basePoints: 100, // points for the first (easiest) question
|
|
13
|
+
stepPoints: 50, // extra points per difficulty step
|
|
14
|
+
guestMultiplier: 1.5, // non-experts earn this multiple
|
|
15
|
+
fraudPenalty: 0.5, // experts lose this fraction when wrong on their own subject
|
|
16
|
+
maxPlayers: 12,
|
|
17
|
+
generateConcurrency: 4,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function pointsFor(settings, questionIndex) {
|
|
21
|
+
return settings.basePoints + settings.stepPoints * questionIndex;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function shuffle(list) {
|
|
25
|
+
const a = [...list];
|
|
26
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
27
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
28
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
29
|
+
}
|
|
30
|
+
return a;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class Game extends EventEmitter {
|
|
34
|
+
constructor({ ai, settings = {} }) {
|
|
35
|
+
super();
|
|
36
|
+
this.ai = ai;
|
|
37
|
+
this.settings = { ...DEFAULTS, ...settings };
|
|
38
|
+
this.players = new Map();
|
|
39
|
+
this.phase = 'lobby';
|
|
40
|
+
this.join = null;
|
|
41
|
+
this.notice = null;
|
|
42
|
+
this.epoch = 0;
|
|
43
|
+
this.timer = null;
|
|
44
|
+
this.onTimeout = null;
|
|
45
|
+
this.deadline = null;
|
|
46
|
+
this.subjects = [];
|
|
47
|
+
this.gen = [];
|
|
48
|
+
this.si = 0;
|
|
49
|
+
this.qi = 0;
|
|
50
|
+
this.answers = new Map();
|
|
51
|
+
this.reveal = null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- connections -------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
connect(id, rawName, isHost) {
|
|
57
|
+
const existing = this.players.get(id);
|
|
58
|
+
if (existing) {
|
|
59
|
+
existing.online = true;
|
|
60
|
+
this.changed();
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
if (this.phase !== 'lobby') return { error: 'That game has already started.' };
|
|
64
|
+
if (this.players.size >= this.settings.maxPlayers) return { error: 'That game is full.' };
|
|
65
|
+
let name = clean(rawName, 20) || 'Player';
|
|
66
|
+
const taken = (n) => [...this.players.values()].some((p) => p.name.toLowerCase() === n.toLowerCase());
|
|
67
|
+
for (let i = 2; taken(name); i++) name = `${clean(rawName, 20) || 'Player'} ${i}`;
|
|
68
|
+
this.players.set(id, { id, name, isHost: !!isHost, online: true, score: 0, subject: null, own: [0, 0] });
|
|
69
|
+
this.changed();
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
disconnect(id) {
|
|
74
|
+
const p = this.players.get(id);
|
|
75
|
+
if (!p) return;
|
|
76
|
+
if (this.phase === 'lobby' && !p.isHost) this.players.delete(id);
|
|
77
|
+
else p.online = false;
|
|
78
|
+
this.checkProgress();
|
|
79
|
+
this.changed();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
setJoinInfo(join) {
|
|
83
|
+
this.join = join;
|
|
84
|
+
this.changed();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- incoming messages -------------------------------------------------
|
|
88
|
+
|
|
89
|
+
handle(id, msg) {
|
|
90
|
+
const p = this.players.get(id);
|
|
91
|
+
if (!p || !msg || typeof msg.t !== 'string') return {};
|
|
92
|
+
switch (msg.t) {
|
|
93
|
+
case 'start':
|
|
94
|
+
if (p.isHost && this.phase === 'lobby') this.startPick();
|
|
95
|
+
return {};
|
|
96
|
+
case 'pick': {
|
|
97
|
+
if (this.phase !== 'pick' || p.subject) return {};
|
|
98
|
+
const subject = clean(msg.subject, 80);
|
|
99
|
+
if (!subject) return {};
|
|
100
|
+
const key = normalize(subject);
|
|
101
|
+
if ([...this.players.values()].some((o) => o.subject && normalize(o.subject) === key)) {
|
|
102
|
+
return { error: 'Someone already claimed that subject. Go more niche.' };
|
|
103
|
+
}
|
|
104
|
+
p.subject = subject;
|
|
105
|
+
this.changed();
|
|
106
|
+
this.checkProgress();
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
case 'answer': {
|
|
110
|
+
if (this.phase !== 'question' || this.answers.has(id)) return {};
|
|
111
|
+
this.answers.set(id, clean(msg.text, 120));
|
|
112
|
+
this.changed();
|
|
113
|
+
this.checkProgress();
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
case 'skip':
|
|
117
|
+
if (p.isHost && (this.phase === 'intro' || this.phase === 'reveal') && this.onTimeout) this.onTimeout();
|
|
118
|
+
return {};
|
|
119
|
+
case 'again':
|
|
120
|
+
if (p.isHost && this.phase === 'final') this.toLobby(null);
|
|
121
|
+
return {};
|
|
122
|
+
default:
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- phases ------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
changed() {
|
|
130
|
+
this.emit('change');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
setPhase(phase, ms, onTimeout) {
|
|
134
|
+
clearTimeout(this.timer);
|
|
135
|
+
this.epoch++;
|
|
136
|
+
this.phase = phase;
|
|
137
|
+
this.timer = null;
|
|
138
|
+
this.deadline = null;
|
|
139
|
+
this.onTimeout = null;
|
|
140
|
+
if (ms != null) {
|
|
141
|
+
const epoch = this.epoch;
|
|
142
|
+
this.onTimeout = () => {
|
|
143
|
+
if (epoch === this.epoch) onTimeout();
|
|
144
|
+
};
|
|
145
|
+
this.deadline = Date.now() + ms;
|
|
146
|
+
this.timer = setTimeout(this.onTimeout, ms);
|
|
147
|
+
}
|
|
148
|
+
this.changed();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
online() {
|
|
152
|
+
return [...this.players.values()].filter((p) => p.online);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
checkProgress() {
|
|
156
|
+
const online = this.online();
|
|
157
|
+
if (!online.length) return;
|
|
158
|
+
if (this.phase === 'pick' && online.every((p) => p.subject)) this.startGenerating();
|
|
159
|
+
else if (this.phase === 'question' && online.every((p) => this.answers.has(p.id))) this.closeQuestion();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
toLobby(notice) {
|
|
163
|
+
for (const p of [...this.players.values()]) {
|
|
164
|
+
if (!p.online && !p.isHost) this.players.delete(p.id);
|
|
165
|
+
p.score = 0;
|
|
166
|
+
p.subject = null;
|
|
167
|
+
p.own = [0, 0];
|
|
168
|
+
}
|
|
169
|
+
this.subjects = [];
|
|
170
|
+
this.gen = [];
|
|
171
|
+
this.reveal = null;
|
|
172
|
+
this.notice = notice;
|
|
173
|
+
this.setPhase('lobby');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
startPick() {
|
|
177
|
+
this.notice = null;
|
|
178
|
+
for (const p of this.players.values()) {
|
|
179
|
+
p.score = 0;
|
|
180
|
+
p.subject = null;
|
|
181
|
+
p.own = [0, 0];
|
|
182
|
+
}
|
|
183
|
+
this.setPhase('pick', this.settings.pickMs, () => this.startGenerating());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async startGenerating() {
|
|
187
|
+
const pickers = [...this.players.values()].filter((p) => p.subject);
|
|
188
|
+
if (!pickers.length) return this.toLobby('Nobody picked a subject, so the game was called off.');
|
|
189
|
+
this.gen = pickers.map((p) => ({ ownerId: p.id, status: 'working' }));
|
|
190
|
+
this.setPhase('generating');
|
|
191
|
+
const epoch = this.epoch;
|
|
192
|
+
const n = this.settings.questionsPerSubject;
|
|
193
|
+
let lastError = null;
|
|
194
|
+
const results = new Array(pickers.length).fill(null);
|
|
195
|
+
let next = 0;
|
|
196
|
+
const worker = async () => {
|
|
197
|
+
while (next < pickers.length) {
|
|
198
|
+
const i = next++;
|
|
199
|
+
const p = pickers[i];
|
|
200
|
+
for (let attempt = 0; attempt < 2 && !results[i]; attempt++) {
|
|
201
|
+
try {
|
|
202
|
+
results[i] = validateSubject(await this.ai.generateSubject(p.subject, n), p, n);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
lastError = err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (epoch !== this.epoch) return;
|
|
208
|
+
this.gen[i].status = results[i] ? 'done' : 'failed';
|
|
209
|
+
this.changed();
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
await Promise.all(Array.from({ length: this.settings.generateConcurrency }, worker));
|
|
213
|
+
if (epoch !== this.epoch) return;
|
|
214
|
+
this.subjects = shuffle(results.filter(Boolean));
|
|
215
|
+
if (!this.subjects.length) {
|
|
216
|
+
return this.toLobby(`Claude could not write any questions (${lastError?.message ?? 'unknown error'}).`);
|
|
217
|
+
}
|
|
218
|
+
this.startIntro(0);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
startIntro(si) {
|
|
222
|
+
this.si = si;
|
|
223
|
+
this.qi = 0;
|
|
224
|
+
this.reveal = null;
|
|
225
|
+
this.setPhase('intro', this.settings.introMs, () => this.startQuestion(0));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
startQuestion(qi) {
|
|
229
|
+
this.qi = qi;
|
|
230
|
+
this.answers = new Map();
|
|
231
|
+
this.reveal = null;
|
|
232
|
+
this.setPhase('question', this.settings.questionMs, () => this.closeQuestion());
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async closeQuestion() {
|
|
236
|
+
if (this.phase !== 'question') return;
|
|
237
|
+
this.setPhase('judging');
|
|
238
|
+
const epoch = this.epoch;
|
|
239
|
+
const subject = this.subjects[this.si];
|
|
240
|
+
const q = subject.questions[this.qi];
|
|
241
|
+
const players = [...this.players.values()];
|
|
242
|
+
const verdicts = await this.judge(subject, q, players.map((p) => this.answers.get(p.id) ?? ''));
|
|
243
|
+
if (epoch !== this.epoch) return;
|
|
244
|
+
|
|
245
|
+
const x = pointsFor(this.settings, this.qi);
|
|
246
|
+
const results = players.map((p, i) => {
|
|
247
|
+
const isExpert = p.id === subject.ownerId;
|
|
248
|
+
const correct = verdicts[i];
|
|
249
|
+
let delta = 0;
|
|
250
|
+
if (correct) delta = isExpert ? x : Math.round(x * this.settings.guestMultiplier);
|
|
251
|
+
else if (isExpert) delta = -Math.round(x * this.settings.fraudPenalty);
|
|
252
|
+
p.score += delta;
|
|
253
|
+
if (isExpert) {
|
|
254
|
+
p.own[1]++;
|
|
255
|
+
if (correct) p.own[0]++;
|
|
256
|
+
}
|
|
257
|
+
return { id: p.id, answer: this.answers.get(p.id) ?? '', correct, delta, isExpert };
|
|
258
|
+
});
|
|
259
|
+
this.reveal = { answer: q.answer, explanation: q.explanation, results };
|
|
260
|
+
this.setPhase('reveal', this.settings.revealMs, () => this.advance());
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Returns one boolean per answer. Obvious cases are settled locally. */
|
|
264
|
+
async judge(subject, q, texts) {
|
|
265
|
+
const accepted = [q.answer, ...(q.accept ?? [])].map(normalize).filter(Boolean);
|
|
266
|
+
const verdictByKey = new Map();
|
|
267
|
+
const pending = [];
|
|
268
|
+
for (const text of texts) {
|
|
269
|
+
const key = normalize(text);
|
|
270
|
+
if (verdictByKey.has(key)) continue;
|
|
271
|
+
if (!key) verdictByKey.set(key, false);
|
|
272
|
+
else if (accepted.includes(key)) verdictByKey.set(key, true);
|
|
273
|
+
else {
|
|
274
|
+
verdictByKey.set(key, null);
|
|
275
|
+
pending.push({ key, text });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (pending.length) {
|
|
279
|
+
let judged = null;
|
|
280
|
+
try {
|
|
281
|
+
judged = await this.ai.judge({ subject: subject.title, ...q }, pending.map((p) => p.text));
|
|
282
|
+
} catch {
|
|
283
|
+
// Fall back to typo-tolerant matching if the judge is unavailable.
|
|
284
|
+
}
|
|
285
|
+
pending.forEach((p, i) => {
|
|
286
|
+
const v = judged ? !!judged[i] : accepted.some((a) => similar(a, p.key));
|
|
287
|
+
verdictByKey.set(p.key, v);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return texts.map((t) => verdictByKey.get(normalize(t)));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
advance() {
|
|
294
|
+
const subject = this.subjects[this.si];
|
|
295
|
+
if (this.qi + 1 < subject.questions.length) this.startQuestion(this.qi + 1);
|
|
296
|
+
else if (this.si + 1 < this.subjects.length) this.startIntro(this.si + 1);
|
|
297
|
+
else this.setPhase('final');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---- what a given player is allowed to see ------------------------------
|
|
301
|
+
|
|
302
|
+
viewFor(id) {
|
|
303
|
+
const s = this.settings;
|
|
304
|
+
const subject = this.subjects[this.si];
|
|
305
|
+
const inRound = ['intro', 'question', 'judging', 'reveal'].includes(this.phase) && subject;
|
|
306
|
+
const owner = inRound ? this.players.get(subject.ownerId) : null;
|
|
307
|
+
const view = {
|
|
308
|
+
phase: this.phase,
|
|
309
|
+
you: id,
|
|
310
|
+
remainingMs: this.deadline ? Math.max(0, this.deadline - Date.now()) : null,
|
|
311
|
+
totalMs: { pick: s.pickMs, intro: s.introMs, question: s.questionMs, reveal: s.revealMs }[this.phase] ?? null,
|
|
312
|
+
notice: this.notice,
|
|
313
|
+
rules: { guestMultiplier: s.guestMultiplier, fraudPenalty: s.fraudPenalty, questionsPerSubject: s.questionsPerSubject },
|
|
314
|
+
players: [...this.players.values()].map((p) => ({
|
|
315
|
+
id: p.id,
|
|
316
|
+
name: p.name,
|
|
317
|
+
isHost: p.isHost,
|
|
318
|
+
online: p.online,
|
|
319
|
+
score: p.score,
|
|
320
|
+
own: p.own,
|
|
321
|
+
done: this.phase === 'pick' ? !!p.subject : this.phase === 'question' ? this.answers.has(p.id) : false,
|
|
322
|
+
})),
|
|
323
|
+
};
|
|
324
|
+
if (this.phase === 'lobby') view.join = this.join;
|
|
325
|
+
if (this.phase === 'pick') view.yourPick = this.players.get(id)?.subject ?? null;
|
|
326
|
+
if (this.phase === 'generating') view.gen = this.gen;
|
|
327
|
+
if (inRound) {
|
|
328
|
+
view.subject = {
|
|
329
|
+
index: this.si,
|
|
330
|
+
total: this.subjects.length,
|
|
331
|
+
title: subject.title,
|
|
332
|
+
picked: subject.picked,
|
|
333
|
+
description: subject.description,
|
|
334
|
+
ownerId: subject.ownerId,
|
|
335
|
+
ownerName: owner?.name ?? 'Someone',
|
|
336
|
+
questionCount: subject.questions.length,
|
|
337
|
+
minPoints: pointsFor(s, 0),
|
|
338
|
+
maxPoints: pointsFor(s, subject.questions.length - 1),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
if (inRound && this.phase !== 'intro') {
|
|
342
|
+
view.question = {
|
|
343
|
+
index: this.qi,
|
|
344
|
+
total: subject.questions.length,
|
|
345
|
+
text: subject.questions[this.qi].question,
|
|
346
|
+
points: pointsFor(s, this.qi),
|
|
347
|
+
yourAnswer: this.answers.has(id) ? this.answers.get(id) : null,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (this.phase === 'reveal') view.reveal = this.reveal;
|
|
351
|
+
if (this.phase === 'final') {
|
|
352
|
+
view.subjects = this.subjects.map((sub) => ({ title: sub.title, ownerId: sub.ownerId }));
|
|
353
|
+
}
|
|
354
|
+
return view;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function validateSubject(raw, player, n) {
|
|
359
|
+
const questions = (Array.isArray(raw?.questions) ? raw.questions : [])
|
|
360
|
+
.filter((q) => q && typeof q.question === 'string' && typeof q.answer === 'string' && q.question.trim() && q.answer.trim())
|
|
361
|
+
.slice(0, n)
|
|
362
|
+
.map((q) => ({
|
|
363
|
+
question: clean(q.question, 400),
|
|
364
|
+
answer: clean(q.answer, 120),
|
|
365
|
+
accept: (Array.isArray(q.accept) ? q.accept : []).filter((a) => typeof a === 'string').map((a) => clean(a, 120)),
|
|
366
|
+
explanation: clean(q.explanation, 300),
|
|
367
|
+
}));
|
|
368
|
+
if (!questions.length) throw new Error('no usable questions were generated');
|
|
369
|
+
return {
|
|
370
|
+
ownerId: player.id,
|
|
371
|
+
picked: player.subject,
|
|
372
|
+
title: clean(raw.title, 80) || player.subject,
|
|
373
|
+
description: clean(raw.description, 400),
|
|
374
|
+
questions,
|
|
375
|
+
};
|
|
376
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// WebSocket server the host runs. Guests (and the host's own UI) connect here.
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { WebSocketServer } from 'ws';
|
|
6
|
+
|
|
7
|
+
export async function startServer({ game, port = 4242, hostKey }) {
|
|
8
|
+
const server = http.createServer((req, res) => {
|
|
9
|
+
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
|
|
10
|
+
res.end('This is a So-Called Expert game server.\nJoin from a terminal with: npx so-called-expert join <code>\n');
|
|
11
|
+
});
|
|
12
|
+
const wss = new WebSocketServer({ server, maxPayload: 16 * 1024 });
|
|
13
|
+
const sockets = new Map(); // player id -> ws
|
|
14
|
+
const idByToken = new Map(); // secret client token -> public player id
|
|
15
|
+
|
|
16
|
+
const send = (ws, msg) => {
|
|
17
|
+
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
game.on('change', () => {
|
|
21
|
+
for (const [id, ws] of sockets) send(ws, { t: 'state', view: game.viewFor(id) });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
wss.on('connection', (ws) => {
|
|
25
|
+
let id = null;
|
|
26
|
+
ws.isAlive = true;
|
|
27
|
+
ws.on('pong', () => (ws.isAlive = true));
|
|
28
|
+
ws.on('error', () => {});
|
|
29
|
+
ws.on('message', (raw) => {
|
|
30
|
+
let msg;
|
|
31
|
+
try {
|
|
32
|
+
msg = JSON.parse(raw.toString());
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (!msg || typeof msg !== 'object') return;
|
|
37
|
+
if (msg.t === 'hello') {
|
|
38
|
+
if (id || typeof msg.token !== 'string' || msg.token.length < 16 || msg.token.length > 64) return;
|
|
39
|
+
const pid = idByToken.get(msg.token) ?? crypto.randomBytes(4).toString('hex');
|
|
40
|
+
const result = game.connect(pid, msg.name, !!hostKey && msg.hostKey === hostKey);
|
|
41
|
+
if (result.error) {
|
|
42
|
+
send(ws, { t: 'error', message: result.error, fatal: true });
|
|
43
|
+
return ws.close();
|
|
44
|
+
}
|
|
45
|
+
idByToken.set(msg.token, pid);
|
|
46
|
+
const old = sockets.get(pid);
|
|
47
|
+
id = pid;
|
|
48
|
+
sockets.set(pid, ws);
|
|
49
|
+
if (old && old !== ws) old.close();
|
|
50
|
+
send(ws, { t: 'state', view: game.viewFor(pid) });
|
|
51
|
+
} else if (id) {
|
|
52
|
+
const result = game.handle(id, msg);
|
|
53
|
+
if (result?.error) send(ws, { t: 'error', message: result.error });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
ws.on('close', () => {
|
|
57
|
+
if (id && sockets.get(id) === ws) {
|
|
58
|
+
sockets.delete(id);
|
|
59
|
+
game.disconnect(id);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Keeps tunnel connections warm and notices players whose connection died.
|
|
65
|
+
const heartbeat = setInterval(() => {
|
|
66
|
+
for (const ws of wss.clients) {
|
|
67
|
+
if (!ws.isAlive) {
|
|
68
|
+
ws.terminate();
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
ws.isAlive = false;
|
|
72
|
+
ws.ping();
|
|
73
|
+
}
|
|
74
|
+
}, 15_000);
|
|
75
|
+
heartbeat.unref();
|
|
76
|
+
|
|
77
|
+
const listen = (p) =>
|
|
78
|
+
new Promise((resolve, reject) => {
|
|
79
|
+
server.once('error', reject);
|
|
80
|
+
server.listen(p, () => {
|
|
81
|
+
server.removeListener('error', reject);
|
|
82
|
+
resolve();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
await listen(port);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (err.code !== 'EADDRINUSE') throw err;
|
|
89
|
+
await listen(0); // preferred port is busy: let the OS choose one
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
port: server.address().port,
|
|
94
|
+
close() {
|
|
95
|
+
clearInterval(heartbeat);
|
|
96
|
+
for (const ws of wss.clients) ws.terminate();
|
|
97
|
+
server.close();
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** First non-internal IPv4 address, for same-network joins. */
|
|
103
|
+
export function lanAddress() {
|
|
104
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
105
|
+
for (const addr of list ?? []) {
|
|
106
|
+
if (addr.family === 'IPv4' && !addr.internal) return addr.address;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
package/src/text.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Small text helpers shared by the game engine and the terminal UI.
|
|
2
|
+
|
|
3
|
+
/** Lowercase, strip accents/punctuation/leading articles, collapse whitespace. */
|
|
4
|
+
export function normalize(s) {
|
|
5
|
+
return String(s ?? '')
|
|
6
|
+
.normalize('NFKD')
|
|
7
|
+
.replace(/[̀-ͯ]/g, '')
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/&/g, ' and ')
|
|
10
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
11
|
+
.replace(/\s+/g, ' ')
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/^(the|a|an) /, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function levenshtein(a, b) {
|
|
17
|
+
if (a === b) return 0;
|
|
18
|
+
if (!a.length) return b.length;
|
|
19
|
+
if (!b.length) return a.length;
|
|
20
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
21
|
+
for (let i = 1; i <= a.length; i++) {
|
|
22
|
+
const cur = [i];
|
|
23
|
+
for (let j = 1; j <= b.length; j++) {
|
|
24
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
25
|
+
}
|
|
26
|
+
prev = cur;
|
|
27
|
+
}
|
|
28
|
+
return prev[b.length];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Typo-tolerant comparison of two already-normalized strings. */
|
|
32
|
+
export function similar(a, b) {
|
|
33
|
+
if (!a || !b) return false;
|
|
34
|
+
if (a === b) return true;
|
|
35
|
+
const longest = Math.max(a.length, b.length);
|
|
36
|
+
if (longest < 5) return false;
|
|
37
|
+
return levenshtein(a, b) / longest <= 0.2;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Word-wrap plain text (no ANSI codes) to the given width. */
|
|
41
|
+
export function wrap(text, width) {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const para of String(text ?? '').split('\n')) {
|
|
44
|
+
let line = '';
|
|
45
|
+
for (let word of para.split(/\s+/).filter(Boolean)) {
|
|
46
|
+
while (word.length > width) {
|
|
47
|
+
if (line) { out.push(line); line = ''; }
|
|
48
|
+
out.push(word.slice(0, width));
|
|
49
|
+
word = word.slice(width);
|
|
50
|
+
}
|
|
51
|
+
if (!line) line = word;
|
|
52
|
+
else if (line.length + 1 + word.length <= width) line += ' ' + word;
|
|
53
|
+
else { out.push(line); line = word; }
|
|
54
|
+
}
|
|
55
|
+
out.push(line);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Remove control characters and trim to a maximum length. */
|
|
61
|
+
export function clean(s, max) {
|
|
62
|
+
return String(s ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const TUNNEL_DOMAIN = 'trycloudflare.com';
|
|
66
|
+
|
|
67
|
+
/** Turn a join code, host:port, or URL into a WebSocket URL. */
|
|
68
|
+
export function joinTargetToUrl(target) {
|
|
69
|
+
const t = String(target).trim();
|
|
70
|
+
if (/^wss?:\/\//i.test(t)) return t;
|
|
71
|
+
if (/^https?:\/\//i.test(t)) return t.replace(/^http/i, 'ws');
|
|
72
|
+
if (/^[a-z0-9-]+$/i.test(t) && /[a-z]/i.test(t) && t.toLowerCase() !== 'localhost') {
|
|
73
|
+
return `wss://${t.toLowerCase()}.${TUNNEL_DOMAIN}`;
|
|
74
|
+
}
|
|
75
|
+
if (t.endsWith('.' + TUNNEL_DOMAIN)) return `wss://${t}`;
|
|
76
|
+
return `ws://${t}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Extract the short join code from a quick-tunnel URL. */
|
|
80
|
+
export function urlToJoinCode(url) {
|
|
81
|
+
const m = /^https:\/\/([a-z0-9-]+)\.trycloudflare\.com/i.exec(url);
|
|
82
|
+
return m ? m[1] : url;
|
|
83
|
+
}
|
package/src/tunnel.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Opens a free Cloudflare "quick tunnel" so guests can reach the host's
|
|
2
|
+
// machine over the internet without any port forwarding or account.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
const URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @returns {Promise<{ url: string, stop: () => void }>}
|
|
10
|
+
*/
|
|
11
|
+
export async function startTunnel(port, { timeoutMs = 30_000 } = {}) {
|
|
12
|
+
const { bin, install } = await import('cloudflared');
|
|
13
|
+
if (!fs.existsSync(bin)) await install(bin);
|
|
14
|
+
|
|
15
|
+
const child = spawn(bin, ['tunnel', '--url', `http://127.0.0.1:${port}`, '--no-autoupdate'], {
|
|
16
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
17
|
+
});
|
|
18
|
+
const stop = () => {
|
|
19
|
+
if (!child.killed) child.kill();
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const url = await new Promise((resolve, reject) => {
|
|
23
|
+
let log = '';
|
|
24
|
+
const timer = setTimeout(() => reject(new Error('timed out waiting for the tunnel')), timeoutMs);
|
|
25
|
+
const onData = (chunk) => {
|
|
26
|
+
log = (log + chunk.toString()).slice(-8000);
|
|
27
|
+
const match = URL_PATTERN.exec(log);
|
|
28
|
+
// Only hand out the link once cloudflared reports the tunnel is actually connected.
|
|
29
|
+
if (match && /Registered tunnel connection/i.test(log)) {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
resolve(match[0]);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
child.stdout.on('data', onData);
|
|
35
|
+
child.stderr.on('data', onData);
|
|
36
|
+
child.once('error', (err) => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
reject(err);
|
|
39
|
+
});
|
|
40
|
+
child.once('exit', (code) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
reject(new Error(`cloudflared exited with code ${code}`));
|
|
43
|
+
});
|
|
44
|
+
}).catch((err) => {
|
|
45
|
+
stop();
|
|
46
|
+
throw err;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return { url, stop };
|
|
50
|
+
}
|