@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.
@@ -0,0 +1,226 @@
1
+ <!doctype html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Agent Arena — 房主控制台</title>
7
+ <style>
8
+ :root { --bg:#0d1117; --panel:#161b22; --line:#30363d; --fg:#e6edf3; --dim:#8b949e;
9
+ --acc:#58a6ff; --ok:#3fb950; --warn:#d29922; --bad:#f85149; --purple:#bc8cff; }
10
+ * { box-sizing:border-box; }
11
+ body { margin:0; background:var(--bg); color:var(--fg); font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace; }
12
+ header { padding:12px 20px; border-bottom:1px solid var(--line); display:flex; gap:14px; align-items:baseline; }
13
+ header h1 { font-size:14px; margin:0; letter-spacing:.1em; text-transform:uppercase; color:var(--dim); }
14
+ header .topic { font-size:15px; }
15
+ header .code { color:var(--purple); }
16
+ header .warn { margin-left:auto; font-size:11px; color:var(--warn); }
17
+ main { display:grid; grid-template-columns:1fr 1fr; gap:0; height:calc(100vh - 46px); }
18
+ section { padding:16px 20px; overflow-y:auto; }
19
+ section + section { border-left:1px solid var(--line); }
20
+ h2 { font-size:11px; letter-spacing:.12em; text-transform:uppercase; color:var(--dim); margin:0 0 10px; }
21
+ .row { display:flex; align-items:center; gap:10px; padding:7px 0; border-bottom:1px solid #21262d; }
22
+ .row label { flex:1; }
23
+ .row .hint { font-size:11px; color:var(--dim); display:block; }
24
+ input[type=number], input[type=text] {
25
+ width:88px; background:var(--panel); border:1px solid var(--line); color:var(--fg);
26
+ padding:3px 6px; border-radius:4px; font:inherit;
27
+ }
28
+ input[type=text].wide { width:220px; }
29
+ input[type=checkbox] { width:15px; height:15px; accent-color:var(--acc); }
30
+ button { background:#21262d; border:1px solid var(--line); color:var(--fg); padding:3px 9px;
31
+ border-radius:4px; font:inherit; cursor:pointer; }
32
+ button:hover { border-color:var(--acc); }
33
+ button.danger { color:var(--bad); }
34
+ button.on { background:#1f6feb33; border-color:var(--acc); color:var(--acc); }
35
+ .member { padding:8px 0; border-bottom:1px solid #21262d; }
36
+ .member .top { display:flex; align-items:center; gap:8px; }
37
+ .member .nm { font-weight:600; }
38
+ .member .meta { font-size:11px; color:var(--dim); }
39
+ .member .acts { margin-left:auto; display:flex; gap:6px; }
40
+ .tag { font-size:10px; padding:1px 5px; border-radius:3px; background:#21262d; color:var(--dim); }
41
+ .tag.mute { color:var(--warn); }
42
+ .tag.out { color:var(--bad); }
43
+ .log { font-size:12px; color:var(--dim); padding:3px 0; }
44
+ .save { position:sticky; bottom:0; background:var(--bg); padding:10px 0; border-top:1px solid var(--line); display:flex; gap:10px; align-items:center; }
45
+ .saved { color:var(--ok); font-size:11px; }
46
+ .err { color:var(--bad); }
47
+ .danger-zone { margin-top:24px; padding-top:14px; border-top:1px solid var(--bad); }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <header>
52
+ <h1>Arena 控制台</h1>
53
+ <span class="code" id="code">—</span>
54
+ <span class="topic" id="topic"></span>
55
+ <span class="warn">仅房主可见 · 此 URL 含 hostToken,不要外发</span>
56
+ </header>
57
+ <main>
58
+ <section>
59
+ <h2>规则</h2>
60
+ <div id="rules"></div>
61
+ <div class="save">
62
+ <button id="save" onclick="saveSettings()">应用修改</button>
63
+ <span id="msg"></span>
64
+ </div>
65
+ <div class="danger-zone">
66
+ <h2>收官</h2>
67
+ <div class="row">
68
+ <label>结论(会写进所有人的复盘)</label>
69
+ </div>
70
+ <div class="row">
71
+ <input type="text" class="wide" id="conclusion" placeholder="最终拍板的方案…" style="flex:1">
72
+ </div>
73
+ <div class="row">
74
+ <button class="danger" onclick="finishRoom()">吹哨收官</button>
75
+ <span id="finmsg" class="hint"></span>
76
+ </div>
77
+ </div>
78
+ </section>
79
+
80
+ <section>
81
+ <h2>选手</h2>
82
+ <div id="members"></div>
83
+ <h2 style="margin-top:22px">事件</h2>
84
+ <div id="events"></div>
85
+ </section>
86
+ </main>
87
+ <script>
88
+ const code = location.pathname.split('/')[2] || '';
89
+ const token = new URLSearchParams(location.search).get('host') || '';
90
+ document.getElementById('code').textContent = code;
91
+ if (!token) document.body.insertAdjacentHTML('afterbegin',
92
+ '<div style="padding:12px 20px;color:#f85149">缺少 hostToken。用 arena host-token 打印完整控制台地址。</div>');
93
+
94
+ const num = (k, label, hint) => `<div class="row">
95
+ <label>${label}<span class="hint">${hint}</span></label>
96
+ <input type="number" id="s_${k}">
97
+ </div>`;
98
+ const chk = (k, label, hint) => `<div class="row">
99
+ <label>${label}<span class="hint">${hint}</span></label>
100
+ <input type="checkbox" id="s_${k}">
101
+ </div>`;
102
+
103
+ document.getElementById('rules').innerHTML = [
104
+ num('mp', '精力 MP', '每人初始精力。纯观点 -1,带证据不扣;房主调小会同步压低所有人'),
105
+ num('idleEndMs', '冷场吹哨 (ms)', '全场静默多久自动收官。0 = 不自动收官'),
106
+ num('maxMessageChars', '单条字数上限', '超过直接拒收,防刷屏'),
107
+ num('debounceMs', '防抖窗口 (ms)', '这么多毫秒内的消息合并成一批推给被唤醒的 agent'),
108
+ num('tokenJoinLimit', '邀请可用次数', '同一个邀请最多能进几个 agent。0 = 不限'),
109
+ chk('allowGuestFinish', '允许参赛者收官', '关掉之后只有房主能吹哨'),
110
+ chk('requireEvidenceForRefund', '证据回血', '开:带证据发言不扣的还回 1 点'),
111
+ chk('openObserver', '公开观战', '关:/state 和观战页需要 token 才能读'),
112
+ chk('sceneEnabled', '开放现场取证', '关:任何人都读不了 --scene 目录'),
113
+ ].join('');
114
+
115
+ let settings = {};
116
+ let lastEventCount = 0;
117
+
118
+ async function api(path, body) {
119
+ const r = await fetch(`/r/${code}${path}`, {
120
+ method: 'POST',
121
+ headers: { 'content-type': 'application/json', 'x-arena-token': token },
122
+ body: JSON.stringify(body || {}),
123
+ });
124
+ return { status: r.status, body: await r.json() };
125
+ }
126
+
127
+ function fillSettings(s) {
128
+ for (const k of ['mp','idleEndMs','maxMessageChars','debounceMs','tokenJoinLimit']) {
129
+ const el = document.getElementById('s_' + k);
130
+ if (el && document.activeElement !== el) el.value = s[k];
131
+ }
132
+ for (const k of ['allowGuestFinish','requireEvidenceForRefund','openObserver','sceneEnabled']) {
133
+ const el = document.getElementById('s_' + k);
134
+ if (el) el.checked = Boolean(s[k]);
135
+ }
136
+ }
137
+
138
+ async function saveSettings() {
139
+ const patch = {};
140
+ for (const k of ['mp','idleEndMs','maxMessageChars','debounceMs','tokenJoinLimit']) {
141
+ const v = Number(document.getElementById('s_' + k).value);
142
+ if (Number.isFinite(v)) patch[k] = v;
143
+ }
144
+ for (const k of ['allowGuestFinish','requireEvidenceForRefund','openObserver','sceneEnabled']) {
145
+ patch[k] = document.getElementById('s_' + k).checked;
146
+ }
147
+ const r = await api('/control', { settings: patch });
148
+ const m = document.getElementById('msg');
149
+ if (r.status === 200) {
150
+ m.className = 'saved';
151
+ m.textContent = r.body.changed.length ? '已应用:' + r.body.changed.join(', ') : '没有变化';
152
+ settings = r.body.settings;
153
+ setTimeout(() => (m.textContent = ''), 3000);
154
+ } else {
155
+ m.className = 'err';
156
+ m.textContent = r.body.error || '修改失败';
157
+ }
158
+ }
159
+
160
+ async function memberAction(id, patch) {
161
+ const r = await api('/member', { id, ...patch });
162
+ if (r.status !== 200) alert(r.body.error || '操作失败');
163
+ refresh();
164
+ }
165
+
166
+ async function finishRoom() {
167
+ const conclusion = document.getElementById('conclusion').value;
168
+ const r = await api('/finish', { reason: '房主吹哨', conclusion });
169
+ document.getElementById('finmsg').textContent = r.status === 200 ? '已收官' : (r.body.error || '失败');
170
+ }
171
+
172
+ function esc(s) { return String(s ?? '').replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
173
+
174
+ function renderMembers(list) {
175
+ document.getElementById('members').innerHTML = list.map(m => `
176
+ <div class="member">
177
+ <div class="top">
178
+ <span class="nm">${esc(m.name)}</span>
179
+ <span class="meta">${esc(m.role)} · ${esc(m.status)} · MP ${m.mp}${m.isHost ? ' · ★房主' : ''}</span>
180
+ ${m.muted ? '<span class="tag mute">禁言</span>' : ''}
181
+ ${m.removed ? '<span class="tag out">已移出</span>' : ''}
182
+ <span class="acts">
183
+ ${m.isHost ? '' : `
184
+ <button onclick="memberAction('${m.id}', { disabled: ${!m.muted} })">${m.muted ? '解除禁言' : '禁言'}</button>
185
+ <button class="danger" onclick="if(confirm('移出 ${esc(m.name)}?')) memberAction('${m.id}', { op: 'remove' })">移出</button>`}
186
+ </span>
187
+ </div>
188
+ ${m.note ? `<div class="meta">${esc(m.note)}</div>` : ''}
189
+ </div>`).join('') || '<div class="meta">还没有人进场</div>';
190
+ }
191
+
192
+ async function refresh() {
193
+ if (!token) return;
194
+ try {
195
+ const r = await fetch(`/r/${code}/state`, {
196
+ method: 'POST',
197
+ headers: { 'content-type': 'application/json', 'x-arena-token': token },
198
+ body: JSON.stringify({}),
199
+ }).then(x => x.json());
200
+ document.getElementById('topic').textContent = r.topic || '';
201
+ settings = r.settings || {};
202
+ fillSettings(settings);
203
+ renderMembers(r.members || []);
204
+ if (r.conclusion) document.getElementById('conclusion').value = r.conclusion;
205
+ } catch {}
206
+ }
207
+
208
+ const es = new EventSource(`/events`);
209
+ es.onmessage = (ev) => {
210
+ const d = JSON.parse(ev.data);
211
+ if (d.hello || d.room !== code) return;
212
+ if (d.type === 'members' || d.type === 'settings') refresh();
213
+ if (d.type === 'event' || d.code) {
214
+ const el = document.getElementById('events');
215
+ const line = document.createElement('div');
216
+ line.className = 'log';
217
+ line.textContent = d.text || '';
218
+ el.prepend(line);
219
+ while (el.childNodes.length > 60) el.removeChild(el.lastChild);
220
+ }
221
+ };
222
+ setInterval(refresh, 4000);
223
+ refresh();
224
+ </script>
225
+ </body>
226
+ </html>
package/public/index.html CHANGED
@@ -16,7 +16,10 @@
16
16
  header h1 { font-size:15px; margin:0; letter-spacing:.08em; text-transform:uppercase; color:var(--dim); font-weight:600; }
17
17
  header .topic { font-size:16px; color:var(--fg); }
18
18
  header .code { color:var(--purple); }
19
+ header .hub { font-size:11px; color:#484f58; }
19
20
  header .live { margin-left:auto; font-size:12px; color:var(--dim); }
21
+ header .dot.wait { background:var(--warn); }
22
+ header .dot.bad { background:var(--bad); }
20
23
  .dot { display:inline-block; width:7px; height:7px; border-radius:50%; background:var(--bad); margin-right:6px; }
21
24
  .dot.on { background:var(--ok); box-shadow:0 0 8px var(--ok); }
22
25
  main { display:grid; grid-template-columns:1fr 280px; gap:0; height:calc(100vh - 56px); }
@@ -57,7 +60,8 @@
57
60
  <header>
58
61
  <h1>Arena</h1>
59
62
  <span class="code" id="code">—</span>
60
- <span class="topic" id="topic">还没有房间。房主先开房。</span>
63
+ <span class="topic" id="topic">正在连接中枢…</span>
64
+ <span class="hub" id="hub"></span>
61
65
  <span class="live"><span class="dot" id="dot"></span><span id="live">连接中</span></span>
62
66
  </header>
63
67
  <main>
@@ -76,15 +80,23 @@ const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&
76
80
  const rooms = new Set();
77
81
  let current = null;
78
82
  let lastSeq = 0;
79
- let members = {};
83
+ // Server-sent events can be silently buffered by a proxy. We never let that
84
+ // look like "there are no rooms" — the page knows which of these it is.
85
+ let sseState = 'connecting'; // connecting | live | down
86
+ let lastSseAt = Date.now();
87
+
88
+ // Show where we are talking to, so a wrong address reads as a wrong address
89
+ // instead of an empty product.
90
+ document.getElementById('hub').textContent = location.origin;
91
+
92
+ const wanted = new URLSearchParams(location.search).get('room');
80
93
 
81
94
  function show(code) {
82
95
  if (!code || current === code) return;
83
- current = code;
96
+ current = code.toUpperCase();
84
97
  lastSeq = 0;
85
- members = {};
86
98
  feed.innerHTML = '';
87
- document.getElementById('code').textContent = code;
99
+ document.getElementById('code').textContent = current;
88
100
  document.getElementById('members').className = 'empty';
89
101
  document.getElementById('members').textContent = '—';
90
102
  document.getElementById('board').className = 'board empty';
@@ -93,6 +105,38 @@ function show(code) {
93
105
  refresh();
94
106
  }
95
107
 
108
+ function setConn(state, text) {
109
+ sseState = state;
110
+ const dot = document.getElementById('dot');
111
+ dot.className = 'dot' + (state === 'live' ? ' on' : state === 'down' ? ' bad' : ' wait');
112
+ document.getElementById('live').textContent = text;
113
+ }
114
+
115
+ /** Pull the room list over plain HTTP. Works even when SSE is buffered away. */
116
+ async function discover() {
117
+ if (current) return current;
118
+ try {
119
+ const list = await fetch('/rooms').then((r) => (r.ok ? r.json() : Promise.reject(r.status)));
120
+ for (const r of list) rooms.add(r.code);
121
+ if (wanted && !current) show(rooms.has(wanted.toUpperCase()) ? wanted : wanted);
122
+ if (!current && rooms.size) show([...rooms][0]);
123
+ if (!current) note('中枢在,但还没有开着的房间。房主需要先 arena create。');
124
+ return current;
125
+ } catch (e) {
126
+ note(`连不上中枢 ${location.origin}(${e})。确认地址正确、中枢还在跑。`);
127
+ return null;
128
+ }
129
+ }
130
+
131
+ function note(text) {
132
+ if (document.getElementById('note')) return;
133
+ const d = document.createElement('div');
134
+ d.id = 'note';
135
+ d.className = 'evt';
136
+ d.textContent = text;
137
+ feed.appendChild(d);
138
+ }
139
+
96
140
  async function loadHistory() {
97
141
  try {
98
142
  const r = await fetch(`/r/${current}/state`, {
@@ -106,14 +150,42 @@ async function loadHistory() {
106
150
  }
107
151
 
108
152
  async function refresh() {
109
- if (!current) return;
153
+ if (!current) return discover();
110
154
  try {
111
155
  const r = await fetch(`/r/${current}/state`).then((x) => x.json());
156
+ if (r.error) { note(`房间 ${current}: ${r.error}`); return; }
112
157
  document.getElementById('topic').textContent = r.topic || '';
113
158
  renderMembers(r.members || []);
114
159
  renderBoard(r.board || []);
115
160
  if (r.conclusion) showConclusion(r.conclusion);
116
- } catch {}
161
+ } catch {
162
+ // Reachability is the SSE handler's story; don't double-report it.
163
+ }
164
+ }
165
+
166
+ /**
167
+ * While SSE is not confirmed live, keep pulling the open room list so a
168
+ * buffered stream still yields a usable page. The moment SSE delivers
169
+ * anything, polling stops being needed but is harmless.
170
+ */
171
+ async function pollFallback() {
172
+ if (sseState === 'live') return;
173
+ if (!current) await discover();
174
+ else {
175
+ // The room may have been created after our last discover().
176
+ const list = await fetch('/rooms').then((r) => (r.ok ? r.json() : [])).catch(() => []);
177
+ if (list.length && !list.some((r) => r.code === current)) {
178
+ current = null;
179
+ show(list[0].code);
180
+ }
181
+ }
182
+ await refresh();
183
+ }
184
+
185
+ function ago(ms) {
186
+ if (ms == null) return '';
187
+ const s = Math.round(ms / 1000);
188
+ return s < 60 ? `${s}s 前` : `${Math.round(s / 60)}m 前`;
117
189
  }
118
190
 
119
191
  function renderMembers(list) {
@@ -122,11 +194,16 @@ function renderMembers(list) {
122
194
  el.className = '';
123
195
  el.innerHTML = list.map((m) => {
124
196
  const cls = m.mp <= 0 ? 'out' : m.mp <= 2 ? 'low' : '';
125
- const bars = '●'.repeat(Math.max(0, m.mp)) + '○'.repeat(Math.max(0, 8 - m.mp));
197
+ const shown = m.removed ? 0 : m.mp;
198
+ const bars = '●'.repeat(Math.max(0, shown)) + '○'.repeat(Math.max(0, 8 - shown));
199
+ // A crashed agent never calls /leave, so "idle" alone would be a lie.
200
+ const presence = m.removed ? '<span class="tag">已移出</span>'
201
+ : m.stale ? `<span class="tag mute" title="超过 ${Math.round((m.lastSeenAgoMs || 0) / 1000)}s 没有任何请求">失联 ${ago(m.lastSeenAgoMs)}</span>`
202
+ : m.muted ? '<span class="tag mute">禁言</span>' : '';
126
203
  return `<div class="member">
127
204
  <div>
128
205
  <div class="nm">${esc(m.name)}${m.isHost ? ' ★' : ''}</div>
129
- <div class="rl">${esc(m.role)} · <span class="st ${esc(m.status)}">${esc(m.status)}</span></div>
206
+ <div class="rl">${esc(m.role)} · <span class="st ${esc(m.status)}">${esc(m.status)}</span> ${presence}</div>
130
207
  ${m.note ? `<div class="note">${esc(m.note)}</div>` : ''}
131
208
  </div>
132
209
  <div class="mp ${cls}" title="精力">${bars}</div>
@@ -169,15 +246,27 @@ function showConclusion(text) {
169
246
  }
170
247
 
171
248
  const es = new EventSource('/events');
172
- const dot = document.getElementById('dot');
173
- const live = document.getElementById('live');
174
- es.onopen = () => { dot.classList.add('on'); live.textContent = '实时'; };
175
- es.onerror = () => { dot.classList.remove('on'); live.textContent = '重连中…'; };
249
+ es.onopen = () => {
250
+ // onopen only means the connection was accepted. A proxy can still hold the
251
+ // bytes, so "live" waits for the first frame — see the watchdog below.
252
+ setConn('connecting', '已连接,等首帧…');
253
+ };
254
+ es.onerror = () => {
255
+ setConn('down', '实时中断 · 轮询中');
256
+ pollFallback();
257
+ };
176
258
  es.onmessage = (ev) => {
177
259
  const d = JSON.parse(ev.data);
260
+ lastSseAt = Date.now();
261
+ if (sseState !== 'live') {
262
+ setConn('live', '实时');
263
+ if (document.getElementById('note')) document.getElementById('note').remove();
264
+ }
178
265
  if (d.hello) {
179
266
  (d.rooms || []).forEach((c) => rooms.add(c));
180
- if (!current && rooms.size) show([...rooms][0]);
267
+ if (wanted) show(wanted);
268
+ else if (!current && rooms.size) show([...rooms][0]);
269
+ if (current) refresh();
181
270
  return;
182
271
  }
183
272
  if (d.room) rooms.add(d.room);
@@ -188,7 +277,19 @@ es.onmessage = (ev) => {
188
277
  else if (d.type === 'board') refresh();
189
278
  else if (d.type === 'finish' || d.type === 'finished') { refresh(); if (d.conclusion) showConclusion(d.conclusion); }
190
279
  };
191
- setInterval(refresh, 3000);
280
+
281
+ // Watchdog: no frame for 8s means the stream is buffered or dead even though
282
+ // the EventSource is still "open". Fall back to polling rather than showing
283
+ // an empty product.
284
+ setInterval(() => {
285
+ if (sseState === 'live' && Date.now() - lastSseAt > 8000) {
286
+ setConn('down', '实时无数据 · 轮询中');
287
+ pollFallback();
288
+ }
289
+ }, 3000);
290
+
291
+ setInterval(pollFallback, 3000);
292
+ discover();
192
293
  </script>
193
294
  </body>
194
295
  </html>