@b1anyu/arena 0.1.0 → 0.2.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/src/room.mjs CHANGED
@@ -10,6 +10,51 @@ export const DEBOUNCE_MS = 1500;
10
10
  export const IDLE_END_MS = 10000;
11
11
  export const INTERRUPTED_KEEP_MS = 4000;
12
12
 
13
+ /**
14
+ * Room settings the host can change from the control page while the debate is
15
+ * running. Everything that used to be a hardcoded constant lives here so a
16
+ * rule change does not need a restart or a code edit.
17
+ */
18
+ export const DEFAULT_SETTINGS = Object.freeze({
19
+ mp: DEFAULT_MP, // energy per agent
20
+ idleEndMs: IDLE_END_MS, // silence before the referee calls time (0 = never)
21
+ allowGuestFinish: false, // may a participant end the room?
22
+ requireEvidenceForRefund: true, // evidence refunds 1 MP only when true
23
+ maxMessageChars: 2000, // hard cap, protects the stream from a wall of text
24
+ debounceMs: DEBOUNCE_MS,
25
+ openObserver: true, // /state readable without a token (the spectator page)
26
+ tokenJoinLimit: 0, // joins allowed per invite token; 0 = unlimited
27
+ // How long without any request before a member is reported stale. An agent
28
+ // that crashes or closes its terminal never calls /leave, so silence is the
29
+ // only signal. Guests default to two listen windows (~2 min).
30
+ staleAfterMs: 150_000,
31
+ sceneEnabled: true, // guests may read the scene at all
32
+ sceneAllow: null, // optional list of path prefixes inside the scene
33
+ sceneDeny: [], // path prefixes to hide even if allowed
34
+ });
35
+
36
+ export function coerceSettings(input, base = DEFAULT_SETTINGS) {
37
+ const out = { ...base };
38
+ if (!input || typeof input !== 'object') return out;
39
+ const num = (v, lo, hi) => (Number.isFinite(Number(v)) ? Math.max(lo, Math.min(hi, Number(v))) : undefined);
40
+ const set = (k, v) => {
41
+ if (v !== undefined) out[k] = v;
42
+ };
43
+ set('mp', num(input.mp, 1, 99));
44
+ set('idleEndMs', num(input.idleEndMs, 0, 3_600_000));
45
+ set('maxMessageChars', num(input.maxMessageChars, 50, 20_000));
46
+ set('debounceMs', num(input.debounceMs, 0, 10_000));
47
+ set('tokenJoinLimit', num(input.tokenJoinLimit, 0, 1000));
48
+ set('staleAfterMs', num(input.staleAfterMs, 10_000, 3_600_000));
49
+ for (const k of ['allowGuestFinish', 'requireEvidenceForRefund', 'openObserver', 'sceneEnabled']) {
50
+ if (typeof input[k] === 'boolean') out[k] = input[k];
51
+ }
52
+ if (Array.isArray(input.sceneAllow)) out.sceneAllow = input.sceneAllow.map(String).slice(0, 50);
53
+ else if (input.sceneAllow === null) out.sceneAllow = null;
54
+ if (Array.isArray(input.sceneDeny)) out.sceneDeny = input.sceneDeny.map(String).slice(0, 50);
55
+ return out;
56
+ }
57
+
13
58
  const now = () => Date.now();
14
59
  const rand = (n) => Math.floor(Math.random() * n);
15
60
 
@@ -34,13 +79,12 @@ export function agentId(name) {
34
79
  * sees the whole exchange instead of four half-sentencess.
35
80
  */
36
81
  export class Room {
37
- constructor({ code, topic, scene = null, mp = DEFAULT_MP, idleEndMs = IDLE_END_MS }) {
82
+ constructor({ code, topic, scene = null, settings = {} }) {
38
83
  this.code = code;
39
84
  this.topic = topic;
40
85
  this.scene = scene;
41
86
  this.hostAgentId = null;
42
- this.mp = mp;
43
- this.idleEndMs = idleEndMs;
87
+ this.settings = coerceSettings(settings);
44
88
  this.createdAt = now();
45
89
  this.closedAt = null;
46
90
  this.onEvent = null;
@@ -53,6 +97,46 @@ export class Room {
53
97
  this.batch = null; // {fromSeq, toSeq, timer}
54
98
  this.conclusion = null;
55
99
  this._waiters = new Set();
100
+ this.jobs = new Map(); // agentId -> {disabled, note} host-controlled
101
+ this.joinCounts = new Map(); // invite token -> joins so far
102
+ }
103
+
104
+ /** Host-side control. Returns what changed, for the audit trail. */
105
+ updateSettings(patch) {
106
+ const before = { ...this.settings };
107
+ this.settings = coerceSettings(patch, this.settings);
108
+ // MP ceiling shrank: clamp everyone rather than leaving them above cap.
109
+ for (const m of this.members.values()) {
110
+ if (!m.isHost) m.mp = Math.min(m.mp, this.settings.mp);
111
+ }
112
+ const changed = Object.keys(this.settings).filter((k) => JSON.stringify(before[k]) !== JSON.stringify(this.settings[k]));
113
+ if (changed.length) {
114
+ this._event('settings', `房主调整规则:${changed.join(', ')}`);
115
+ this._maybeFinishIdle();
116
+ }
117
+ return { changed, settings: this.settings };
118
+ }
119
+
120
+ setMemberControl(id, { disabled, note }) {
121
+ const m = this.members.get(id);
122
+ if (!m) return { ok: false, error: `unknown agent ${id}` };
123
+ if (m.isHost) return { ok: false, error: 'cannot control the host' };
124
+ const job = this.jobs.get(id) || {};
125
+ if (typeof disabled === 'boolean') job.disabled = disabled;
126
+ if (typeof note === 'string') job.note = note.slice(0, 280);
127
+ this.jobs.set(id, job);
128
+ this._event('control', `${m.name}:${disabled === true ? '已禁言' : disabled === false ? '解除禁言' : ''}${note ? ' ' + job.note : ''}`.trim());
129
+ return { ok: true, member: { ...m, disabled: Boolean(job.disabled) } };
130
+ }
131
+
132
+ removeMember(id) {
133
+ const m = this.members.get(id);
134
+ if (!m) return { ok: false, error: `unknown agent ${id}` };
135
+ if (m.isHost) return { ok: false, error: 'cannot remove the host' };
136
+ m.status = 'offline';
137
+ m.removed = true;
138
+ this._event('leave', `${m.name} 被房主移出`);
139
+ return { ok: true };
56
140
  }
57
141
 
58
142
  _add({ name, role, owner, isHost = false, mp }) {
@@ -63,7 +147,9 @@ export class Room {
63
147
  role: role || 'guest',
64
148
  owner: owner || 'anon',
65
149
  isHost,
66
- mp: mp ?? this.mp,
150
+ // A participant who joins after the host raised MP starts full, not
151
+ // stuck at the old ceiling.
152
+ mp: mp ?? this.settings.mp,
67
153
  status: 'idle', // idle | working | waiting | offline
68
154
  note: null,
69
155
  joinedAt: now(),
@@ -83,11 +169,29 @@ export class Room {
83
169
  return e;
84
170
  }
85
171
 
86
- join({ name, role, owner, mp, isHost = false }) {
172
+ join({ name, role, owner, mp, isHost = false, token }) {
173
+ // Host-side join budget: one leaked invite promptly becoming twenty agents
174
+ // is what let a single person drown a room.
175
+ const limit = this.settings.tokenJoinLimit;
176
+ if (limit > 0 && token) {
177
+ const used = this.joinCounts.get(token) || 0;
178
+ if (used >= limit) return { refused: 'join_limit', limit };
179
+ this.joinCounts.set(token, used + 1);
180
+ }
87
181
  // Re-entry with the same name is allowed; ids are unique per join.
88
182
  return this._add({ name, role, owner, isHost, mp });
89
183
  }
90
184
 
185
+ /** Whether a member may act at all (removed / muted). */
186
+ canAct(id) {
187
+ const m = this.members.get(id);
188
+ if (!m) return { ok: false, error: `unknown agent ${id}` };
189
+ if (m.removed) return { ok: false, removed: true, hint: '你已被房主移出房间。' };
190
+ const job = this.jobs.get(id);
191
+ if (job?.disabled) return { ok: false, muted: true, hint: '你已被房主禁言。' };
192
+ return { ok: true };
193
+ }
194
+
91
195
  leave(id) {
92
196
  const m = this.members.get(id);
93
197
  if (!m) return false;
@@ -138,6 +242,7 @@ export class Room {
138
242
  */
139
243
  speak({ from, text, basedOnSeq, force = false, evidence = null, kind = 'text' }) {
140
244
  const m = this.members.get(from);
245
+ const job = this.jobs.get(from) || {};
141
246
  if (!m) return { ok: false, error: `unknown agent ${from}` };
142
247
  if (this.closedAt) return { ok: false, error: 'room is closed' };
143
248
  const body = String(text || '').trim();
@@ -155,6 +260,14 @@ export class Room {
155
260
  };
156
261
  }
157
262
 
263
+ const cap = this.settings.maxMessageChars;
264
+ if (body.length > cap) {
265
+ return { ok: false, tooLong: true, limit: cap, hint: `消息 ${body.length} 字,超过上限 ${cap}。精简后重发。` };
266
+ }
267
+ if (job.disabled) {
268
+ return { ok: false, muted: true, hint: '你已被房主禁言。可以继续 pass 或取证,不能发言。' };
269
+ }
270
+
158
271
  // Energy: plain opinion costs 1 MP; evidence counters do not.
159
272
  const hasEvidence = Boolean(evidence && String(evidence).trim());
160
273
  if (!hasEvidence) {
@@ -166,8 +279,8 @@ export class Room {
166
279
  };
167
280
  }
168
281
  m.mp -= 1;
169
- } else {
170
- m.mp = Math.min(this.mp, m.mp + 1); // evidence refunds at most 1
282
+ } else if (this.settings.requireEvidenceForRefund) {
283
+ m.mp = Math.min(this.settings.mp, m.mp + 1); // evidence refunds at most 1
171
284
  }
172
285
 
173
286
  this.seq += 1;
@@ -269,16 +382,16 @@ export class Room {
269
382
 
270
383
  _scheduleBatch(msg) {
271
384
  if (!this.batch) {
272
- this.batch = { fromSeq: msg.seq, toSeq: msg.seq, endsAt: now() + DEBOUNCE_MS };
385
+ this.batch = { fromSeq: msg.seq, toSeq: msg.seq, endsAt: now() + this.settings.debounceMs };
273
386
  setTimeout(() => {
274
387
  const b = this.batch;
275
388
  this.batch = null;
276
389
  for (const w of this._waiters) w.check();
277
390
  if (b) this._maybeFinishIdle();
278
- }, DEBOUNCE_MS);
391
+ }, this.settings.debounceMs);
279
392
  } else {
280
393
  this.batch.toSeq = msg.seq;
281
- this.batch.endsAt = now() + DEBOUNCE_MS;
394
+ this.batch.endsAt = now() + this.settings.debounceMs;
282
395
  }
283
396
  this._maybeFinishIdle();
284
397
  }
@@ -290,11 +403,13 @@ export class Room {
290
403
  _maybeFinishIdle() {
291
404
  if (this.closedAt) return;
292
405
  clearTimeout(this._idleTimer);
406
+ // 0 disables the referee entirely: the host decides when it ends.
407
+ if (this.settings.idleEndMs === 0) return;
293
408
  if (this.messages.length === 0) return;
294
409
  const busy = [...this.members.values()].some((m) => m.status === 'working');
295
410
  if (busy) return;
296
411
  const sinceLast = now() - (this.messages[this.messages.length - 1]?.ts || 0);
297
- const wait = Math.max(0, this.idleEndMs - sinceLast);
412
+ const wait = Math.max(0, this.settings.idleEndMs - sinceLast);
298
413
  this._idleTimer = setTimeout(() => {
299
414
  if (this.closedAt) return;
300
415
  const stillBusy = [...this.members.values()].some((m) => m.status === 'working');
@@ -313,13 +428,26 @@ export class Room {
313
428
  return this.conclusion;
314
429
  }
315
430
 
431
+ /**
432
+ * An agent that crashed never calls /leave, so `status` cannot be trusted
433
+ * on its own. Anything not heard from for this long is reported as stale
434
+ * separately from online/offline: "idle" and "silently dead" look identical
435
+ * otherwise, and the CLI tells users to do different things about them.
436
+ */
437
+ isStale(m, now_ms = now()) {
438
+ if (m.removed || m.status === 'offline') return false;
439
+ return now_ms - m.lastSeen > this.settings.staleAfterMs;
440
+ }
441
+
316
442
  state() {
443
+ const t = now();
317
444
  return {
318
445
  code: this.code,
319
446
  topic: this.topic,
320
447
  scene: this.scene,
321
448
  seq: this.seq,
322
449
  closed: Boolean(this.closedAt),
450
+ settings: this.settings,
323
451
  members: [...this.members.values()].map((m) => ({
324
452
  id: m.id,
325
453
  name: m.name,
@@ -328,8 +456,12 @@ export class Room {
328
456
  isHost: m.isHost,
329
457
  mp: m.mp,
330
458
  status: m.status,
331
- note: m.note,
459
+ note: this.jobs.get(m.id)?.note || m.note,
332
460
  online: m.status !== 'offline',
461
+ stale: this.isStale(m, t),
462
+ lastSeenAgoMs: m.status === 'offline' ? null : t - m.lastSeen,
463
+ removed: Boolean(m.removed),
464
+ muted: Boolean(this.jobs.get(m.id)?.disabled),
333
465
  })),
334
466
  };
335
467
  }
package/src/scene.mjs CHANGED
@@ -44,6 +44,27 @@ export class Scene {
44
44
  return Boolean(this.root);
45
45
  }
46
46
 
47
+ /**
48
+ * Host-controlled exposure, applied before the path sandbox. `sceneAllow`
49
+ * is an opt-in list of path prefixes; `sceneDeny` always wins.
50
+ */
51
+ checkPolicy(settings, p) {
52
+ if (!settings) return { ok: true };
53
+ const rel = String(p || '.').replace(/^\.\//, '').replace(/^\/+/, '');
54
+ const deny = settings.sceneDeny || [];
55
+ const hitDeny = deny.find((d) => d && (rel === d.replace(/\/$/, '') || rel.startsWith(`${d.replace(/\/$/, '')}/`)));
56
+ if (hitDeny) return { ok: false, error: `房主隐藏了这个位置:${hitDeny}` };
57
+ const allow = settings.sceneAllow;
58
+ if (Array.isArray(allow) && allow.length) {
59
+ const inAllow = rel === '.' || allow.some((a) => {
60
+ const pre = String(a).replace(/^\.\//, '').replace(/\/$/, '');
61
+ return !pre || rel === pre || rel.startsWith(`${pre}/`);
62
+ });
63
+ if (!inAllow) return { ok: false, error: `房主只开放了这些位置:${allow.join(', ')}` };
64
+ }
65
+ return { ok: true };
66
+ }
67
+
47
68
  /** Resolve a caller-supplied path and refuse anything outside the root. */
48
69
  async resolve(p, { allowRoot = true } = {}) {
49
70
  if (!this.enabled) throw new Error('this room has no scene');
package/src/server.mjs CHANGED
@@ -18,6 +18,21 @@ const json = (res, code, body, extra = {}) => {
18
18
  res.end(data);
19
19
  };
20
20
 
21
+ /**
22
+ * Headers that keep an SSE response un-buffered through a reverse proxy.
23
+ * `X-Accel-Buffering` is honoured by nginx; `no-transform` stops intermediaries
24
+ * from re-encoding the stream; `Connection: keep-alive` keeps the socket open.
25
+ */
26
+ function sseHeaders(res) {
27
+ res.writeHead(200, {
28
+ 'content-type': 'text/event-stream; charset=utf-8',
29
+ 'cache-control': 'no-cache, no-transform',
30
+ connection: 'keep-alive',
31
+ 'x-accel-buffering': 'no',
32
+ 'access-control-allow-origin': '*',
33
+ });
34
+ }
35
+
21
36
  async function body(req, limit = 1_000_000) {
22
37
  return new Promise((resolve, reject) => {
23
38
  let n = 0;
@@ -70,15 +85,14 @@ export function createServer({ pageHtml = null } = {}) {
70
85
  }
71
86
  };
72
87
 
73
- const createRoom = ({ topic, scene, name, mp, idleEndMs }) => {
88
+ const createRoom = ({ topic, scene, name, mp, idleEndMs, settings: s }) => {
74
89
  let code;
75
90
  do code = roomCode(); while (rooms.has(code));
76
91
  const room = new Room({
77
92
  code,
78
93
  topic: topic || '(未命名议题)',
79
94
  scene: scene ? { root: scene } : null,
80
- mp,
81
- idleEndMs,
95
+ settings: { mp, idleEndMs, ...(s || {}) },
82
96
  });
83
97
  room.sceneObj = new Scene(scene);
84
98
  room.token = crypto.randomBytes(18).toString('base64url');
@@ -116,19 +130,19 @@ export function createServer({ pageHtml = null } = {}) {
116
130
  if (p === '/health') return json(res, 200, { ok: true, rooms: rooms.size, version: '0.1.0' });
117
131
 
118
132
  if (p === '/' && req.method === 'GET') {
119
- if (!pageHtml) return json(res, 200, { ok: true, hint: 'GET /r/<CODE>/state' });
133
+ const page = pageHtml?.index || (typeof pageHtml === 'string' ? pageHtml : null);
134
+ if (!page) return json(res, 200, { ok: true, hint: 'GET /r/<CODE>/state' });
120
135
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
121
- return res.end(pageHtml);
136
+ return res.end(page);
122
137
  }
123
138
 
124
139
  // Observer SSE (no token: read-only public view of a room)
125
140
  if (p === '/events' && req.method === 'GET') {
126
- res.writeHead(200, {
127
- 'content-type': 'text/event-stream',
128
- 'cache-control': 'no-cache',
129
- connection: 'keep-alive',
130
- 'access-control-allow-origin': '*',
131
- });
141
+ sseHeaders(res);
142
+ // A proxy will happily buffer a small first frame until it has more
143
+ // (cloudflared did exactly this). A padded comment forces the first
144
+ // flush out immediately, before the hello event matters.
145
+ res.write(`: ${' '.repeat(2048)}\n\n`);
132
146
  const send = (s) => res.write(`data: ${s}\n\n`);
133
147
  observers.add(send);
134
148
  res.write(`data: ${JSON.stringify({ hello: true, rooms: [...rooms.keys()] })}\n\n`);
@@ -140,9 +154,12 @@ export function createServer({ pageHtml = null } = {}) {
140
154
  return;
141
155
  }
142
156
 
143
- // List rooms (observer)
157
+ // List rooms (observer). Only the room's shape, not the spectator
158
+ // controls — /rooms must not leak a closed room's contents.
144
159
  if (p === '/rooms' && req.method === 'GET') {
145
- return json(res, 200, [...rooms.values()].map((r) => ({ ...r.state(), createdAt: r.createdAt })));
160
+ return json(res, 200, [...rooms.values()]
161
+ .filter((r) => r.settings.openObserver)
162
+ .map((r) => ({ ...r.state(), createdAt: r.createdAt })));
146
163
  }
147
164
 
148
165
  // Open a room. Any client that can reach the hub may do this; that is
@@ -167,8 +184,21 @@ export function createServer({ pageHtml = null } = {}) {
167
184
  const sub = m[2] || '/';
168
185
  const tok = url.searchParams.get('token') || req.headers['x-arena-token'];
169
186
 
170
- // Public read-only views
187
+ // Host console. The page itself is static; every action it takes is
188
+ // host-token-gated on the server, so serving it here leaks nothing.
189
+ // The token arrives in the fragment-friendly `?host=` query string.
190
+ if (sub === '/control' && req.method === 'GET') {
191
+ const page = pageHtml?.control || null;
192
+ if (!page) return json(res, 404, { error: 'control page missing' });
193
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
194
+ return res.end(page);
195
+ }
196
+
197
+ // Public read-only views, unless the host closed them.
171
198
  if (sub === '/state' || sub === '/') {
199
+ if (!room.settings.openObserver && !auth(room, req, url)) {
200
+ return json(res, 401, { error: '这个房间的实况仅对参与者开放' });
201
+ }
172
202
  const b = await (req.method === 'POST' ? body(req) : {});
173
203
  return json(res, 200, {
174
204
  ...room.state(),
@@ -187,7 +217,13 @@ export function createServer({ pageHtml = null } = {}) {
187
217
  owner: b.owner || b.ownerName || 'anon',
188
218
  isHost: Boolean(b.isHost) && !room.hostAgentId,
189
219
  mp: Number.isFinite(b.mp) ? b.mp : undefined,
220
+ token: tok,
190
221
  });
222
+ if (member.refused === 'join_limit') {
223
+ return json(res, 403, {
224
+ error: `这个邀请最多允许 ${member.limit} 个 Agent 进场,已满。找房主要新邀请。`,
225
+ });
226
+ }
191
227
  return json(res, 200, {
192
228
  agentId: member.id,
193
229
  you: member,
@@ -212,9 +248,15 @@ export function createServer({ pageHtml = null } = {}) {
212
248
  return json(res, 200, { ...out, state: room.state(), board: room.list().map((x) => ({ key: x.key, version: x.version, from: x.from })) });
213
249
  }
214
250
 
215
- if (req.method === 'POST' && ['/speak', '/pass', '/status', '/idle', '/leave', '/board', '/scene', '/finish'].includes(sub)) {
251
+ if (req.method === 'POST' && ['/speak', '/pass', '/status', '/idle', '/leave', '/board', '/scene', '/finish', '/control', '/member'].includes(sub)) {
216
252
  if (!authed) return json(res, 401, { error: 'bad or missing token' });
217
253
  const b = await body(req);
254
+ // Removal and muting are enforced here, not just hidden in the UI.
255
+ // Host control routes are keyed by the target id, not the caller.
256
+ if (!['/leave', '/control', '/member'].includes(sub)) {
257
+ const gate = room.canAct(b.agent);
258
+ if (!gate.ok && sub !== '/pass') return json(res, 403, gate);
259
+ }
218
260
  switch (sub) {
219
261
  case '/speak': {
220
262
  const out = room.speak({
@@ -252,6 +294,11 @@ export function createServer({ pageHtml = null } = {}) {
252
294
  case '/scene': {
253
295
  const sc = room.sceneObj;
254
296
  if (!sc || !sc.enabled) return json(res, 400, { error: 'this room has no scene' });
297
+ if (!room.settings.sceneEnabled && !isHost(room, req, url)) {
298
+ return json(res, 403, { error: '房主关闭了现场取证' });
299
+ }
300
+ const gate = sc.checkPolicy(room.settings, b.path || '.');
301
+ if (!gate.ok) return json(res, 403, gate);
255
302
  const agentName = room.members.get(b.agent)?.name || 'agent';
256
303
  const op = b.op || 'list';
257
304
  let out;
@@ -264,9 +311,30 @@ export function createServer({ pageHtml = null } = {}) {
264
311
  room._event('scene', `${agentName} 正在现场取证:${op}`, { from: b.agent });
265
312
  return json(res, 200, out);
266
313
  }
314
+ case '/control': {
315
+ if (!isHost(room, req, url)) return json(res, 403, { error: '只有房主能改规则' });
316
+ const out = room.updateSettings(b.settings || {});
317
+ room.notify({ type: 'settings', settings: room.settings, changed: out.changed });
318
+ return json(res, 200, { ok: true, ...out });
319
+ }
320
+ case '/member': {
321
+ if (!isHost(room, req, url)) return json(res, 403, { error: '只有房主能管理选手' });
322
+ if (b.op === 'remove') {
323
+ const out = room.removeMember(b.id);
324
+ if (out.ok) room.notify({ type: 'members' });
325
+ return json(res, out.ok ? 200 : 400, out);
326
+ }
327
+ const out = room.setMemberControl(b.id, { disabled: b.disabled, note: b.note });
328
+ if (out.ok) room.notify({ type: 'members' });
329
+ return json(res, out.ok ? 200 : 400, out);
330
+ }
267
331
  case '/finish': {
268
- if (!isHost(room, req, url) && !b.force) {
269
- return json(res, 403, { error: 'only the host can call time (or pass force:true)' });
332
+ const host = isHost(room, req, url);
333
+ if (!host && !b.force && !room.settings.allowGuestFinish) {
334
+ return json(res, 403, { error: '只有房主能吹哨(当前设置不允许参赛者收官)' });
335
+ }
336
+ if (!host && !room.settings.allowGuestFinish && b.force) {
337
+ return json(res, 403, { error: '房主已关闭参赛者收官,force 不再有效' });
270
338
  }
271
339
  room.finish(b.reason || 'host called time', b.conclusion || null);
272
340
  room.notify({ type: 'finish', reason: b.reason });
@@ -283,11 +351,10 @@ export function createServer({ pageHtml = null } = {}) {
283
351
  res.end();
284
352
  };
285
353
  const send = (s) => res.write(`data: ${s}\n\n`);
286
- res.writeHead(200, {
287
- 'content-type': 'text/event-stream',
288
- 'cache-control': 'no-cache',
289
- connection: 'keep-alive',
290
- });
354
+ sseHeaders(res);
355
+ // Same first-flush trick as /events: the bridge can sit silent for
356
+ // minutes and must not be held hostage by a proxy's buffer.
357
+ res.write(`: ${' '.repeat(2048)}\n\n`);
291
358
  for (const msg of room.messages.filter((x) => x.seq > since)) res.write(`data: ${JSON.stringify({ type: 'message', message: msg })}\n\n`);
292
359
  if (!roomStreams.has(room.code)) roomStreams.set(room.code, new Set());
293
360
  roomStreams.get(room.code).add(send);