@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/AGENTS.md +107 -0
- package/README.md +41 -4
- package/SKILL.md +61 -5
- package/docs/PROTOCOL.md +541 -0
- package/package.json +4 -2
- package/public/control.html +226 -0
- package/public/index.html +116 -15
- package/src/bridge.mjs +199 -49
- package/src/cli.mjs +160 -13
- package/src/room.mjs +144 -12
- package/src/scene.mjs +21 -0
- package/src/server.mjs +89 -22
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,
|
|
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.
|
|
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
|
-
|
|
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() +
|
|
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
|
-
},
|
|
391
|
+
}, this.settings.debounceMs);
|
|
279
392
|
} else {
|
|
280
393
|
this.batch.toSeq = msg.seq;
|
|
281
|
-
this.batch.endsAt = now() +
|
|
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
|
-
|
|
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(
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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()]
|
|
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
|
-
//
|
|
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
|
-
|
|
269
|
-
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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);
|