chat: player-to-player tavern chat (4.3.0)

- backend: in-memory ring buffer POST/GET /agents/tavern-chat
  {id,name,text}, last ~60, control-char sanitized
- RpgTavern: Enter/T (or 💬) opens a chat bar; your line floats as a
  canvas speech bubble above your avatar for ~5.5s AND lands in a small
  chat log; others' bubbles render above their avatars (keyed by presence
  id); typing freezes movement
- verified across two browsers: A's message appeared in B's log and as a
  bubble over A's avatar

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
artheru 2026-06-11 15:43:08 +08:00
parent a22e5407ab
commit 3b92827563
6 changed files with 98 additions and 9 deletions

View File

@ -17,7 +17,7 @@
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
</style>
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
<meta name="version" content="4.2.0-world" />
<meta name="version" content="4.3.0-chat" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />

View File

@ -1,6 +1,6 @@
{
"name": "amerc-site",
"version": "4.2.0-world",
"version": "4.3.0-chat",
"private": true,
"type": "module",
"scripts": {

View File

@ -133,6 +133,8 @@ function presenceList(exclude) {
for (const [k, v] of presence) { if (now - v.at > PRESENCE_TTL) { presence.delete(k); continue; } if (k !== exclude) out.push(v); }
return out.slice(0, 50);
}
// tavern chat: a tiny in-memory ring buffer of recent player messages.
const tavernChat = []; let chatSeq = 0;
// file rendezvous: producer's request stream is piped to the consumer's
// response when it claims the token. The server never writes the bytes.
const FILE_CLAIM_MS = 5 * 60 * 1000;
@ -544,6 +546,20 @@ const server = http.createServer(async (req, res) => {
}
if (path === '/agents/presence' && method === 'GET') return send(res, 200, { ok: true, players: presenceList(null) });
// tavern chat (public lobby chat — bubbles + a small log)
if (path === '/agents/tavern-chat' && method === 'POST') {
const b = await readBody(req) || {};
const text = String(b.text || '').replace(/[-]/g, ' ').trim().slice(0, 200);
if (!text) return send(res, 400, { ok: false, error: 'text required' });
const msg = { seq: ++chatSeq, id: String(b.id || '').slice(0, 40), name: String(b.name || 'wanderer').slice(0, 24), text, at: Date.now() };
tavernChat.push(msg); if (tavernChat.length > 60) tavernChat.shift();
return send(res, 200, { ok: true, seq: msg.seq });
}
if (path === '/agents/tavern-chat' && method === 'GET') {
const after = Number(url.searchParams.get('after')) || 0;
return send(res, 200, { ok: true, messages: tavernChat.filter((m) => m.seq > after).slice(-40), seq: chatSeq });
}
// browse classes (public + own private)
if (path === '/agents/classes' && method === 'GET') {
const tag = url.searchParams.get('tag'); const q = (url.searchParams.get('q') || '').toLowerCase(); const mine = url.searchParams.get('mine');

View File

@ -18,6 +18,7 @@ const randomName = () => `${NAME_ADJ[Math.floor(Math.random() * NAME_ADJ.length)
const tile = (ctx, s, c, r, dx, dy, tw = T, th = T) => ctx.drawImage(s, c * T, r * T, tw, th, dx, dy, tw, th);
const chr = (ctx, sheet, col, row, dx, dy) => ctx.drawImage(sheet, col * 64, row * 64, 64, 64, dx, dy, 64, 64);
const roundRect = (c, x, y, w, h, r) => { c.beginPath(); c.moveTo(x + r, y); c.arcTo(x + w, y, x + w, y + h, r); c.arcTo(x + w, y + h, x, y + h, r); c.arcTo(x, y + h, x, y, r); c.arcTo(x, y, x + w, y, r); c.closePath(); };
export default function RpgTavern({ onAction, auth, stats }) {
const canvasRef = useRef(null);
@ -27,7 +28,16 @@ export default function RpgTavern({ onAction, auth, stats }) {
const [ready, setReady] = useState(false);
const [online, setOnline] = useState(0);
const [myName, setMyName] = useState('');
const [chatOpen, setChatOpen] = useState(false);
const [chatText, setChatText] = useState('');
const [chatLog, setChatLog] = useState([]);
const nameRef = useRef('');
const pidRef = useRef('');
if (!pidRef.current) { try { let v = localStorage.getItem('amerc_pid'); if (!v) { v = 'p_' + Math.random().toString(36).slice(2, 10); localStorage.setItem('amerc_pid', v); } pidRef.current = v; } catch { pidRef.current = 'p_' + Math.random().toString(36).slice(2, 10); } }
const saysRef = useRef(new Map()); // id -> { text, until }
const chatTypingRef = useRef(false);
const lastChatSeqRef = useRef(0);
const chatInputRef = useRef(null);
const dialogRef = useRef(null); useEffect(() => { dialogRef.current = dialog; }, [dialog]);
const authRef = useRef(auth); useEffect(() => { authRef.current = auth; }, [auth]);
const statsRef = useRef(stats); useEffect(() => { statsRef.current = stats; }, [stats]);
@ -38,6 +48,14 @@ export default function RpgTavern({ onAction, auth, stats }) {
}, [auth]);
const rename = () => { if (auth?.user) return; const v = (window.prompt('Your tavern name', myName) || '').trim().slice(0, 20); if (v) { nameRef.current = v; setMyName(v); try { localStorage.setItem('amerc_name', v); } catch { /* private mode */ } } };
const advanceDialog = () => setDialog((d) => (d && d.i < d.lines.length - 1 ? { ...d, i: d.i + 1 } : null));
const openChat = () => { const g = gameRef.current; if (g) g.keys.clear(); setChatOpen(true); setTimeout(() => chatInputRef.current?.focus(), 0); };
const sendChat = async () => {
const t = chatText.trim(); setChatText('');
if (!t) { setChatOpen(false); chatInputRef.current?.blur(); return; }
saysRef.current.set(pidRef.current, { text: t, until: Date.now() + 5500 }); // optimistic bubble
try { await api('/agents/tavern-chat', { method: 'POST', body: { id: pidRef.current, name: nameRef.current, text: t } }); } catch { /* offline */ }
};
const onChatKey = (e) => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); sendChat(); } else if (e.key === 'Escape') { e.preventDefault(); setChatOpen(false); chatInputRef.current?.blur(); } };
useEffect(() => {
const S = {}; const keys = Object.keys(SRC); let loaded = 0, raf, paused = false, io;
@ -78,7 +96,7 @@ export default function RpgTavern({ onAction, auth, stats }) {
return corners.some(([cx, cy]) => isSolid(Math.floor(cx / T), Math.floor(cy / T)));
};
const g = { player: { x: px(10), y: py(9), dir: 'up', moving: false, frame: 0, ft: 0 }, keys: new Set(), near: null, f: 0, others: new Map() };
const g = { player: { x: px(10), y: py(9), dir: 'up', moving: false, frame: 0, ft: 0 }, keys: new Set(), near: null, f: 0, others: new Map(), says: saysRef.current };
gameRef.current = g;
const buildStatic = (buf) => {
@ -158,6 +176,19 @@ export default function RpgTavern({ onAction, auth, stats }) {
for (const [lx, ly, lr] of [[40, 96, 40], [560, 96, 40], [W / 2, 60, 70]]) { const gg = ctx.createRadialGradient(lx, ly, 0, lx, ly, lr); gg.addColorStop(0, 'rgba(255,180,90,0.13)'); gg.addColorStop(1, 'rgba(255,170,80,0)'); ctx.fillStyle = gg; ctx.beginPath(); ctx.arc(lx, ly, lr, 0, 7); ctx.fill(); }
ctx.globalCompositeOperation = 'source-over';
const vg = ctx.createRadialGradient(W / 2, H / 2, H * 0.4, W / 2, H / 2, H * 0.85); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(8,4,10,0.42)'); ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H);
// chat speech bubbles (crisp, over everything)
const sayNow = Date.now();
const drawSay = (cx, feetY, text) => {
ctx.font = '10px sans-serif';
const tx = text.length > 28 ? text.slice(0, 27) + '…' : text;
const tw = ctx.measureText(tx).width, bw = tw + 14, bx = cx - bw / 2, by = feetY - 80;
ctx.fillStyle = 'rgba(250,246,236,0.97)'; roundRect(ctx, bx, by, bw, 18, 6); ctx.fill();
ctx.beginPath(); ctx.moveTo(cx - 4, by + 17); ctx.lineTo(cx + 4, by + 17); ctx.lineTo(cx, by + 23); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#241509'; ctx.textAlign = 'center'; ctx.fillText(tx, cx, by + 13); ctx.textAlign = 'left';
};
const mine = g.says.get(pidRef.current);
if (mine && mine.until > sayNow) drawSay(p.x, p.y, mine.text);
for (const [oid, o] of g.others) { const s = g.says.get(oid); if (s && s.until > sayNow) drawSay(o.rx, o.ry, s.text); }
raf = requestAnimationFrame(render);
};
render();
@ -174,8 +205,10 @@ export default function RpgTavern({ onAction, auth, stats }) {
const interact = () => { const g = gameRef.current; if (!g) return; if (dialog) { advanceDialog(); return; } if (g.near) g.near.interact(); };
const onDown = (e) => {
const g = gameRef.current; if (!g) return;
if (chatTypingRef.current) return; // typing in chat let the input handle keys
if (KEY[e.key]) { e.preventDefault(); g.keys.add(KEY[e.key]); }
else if (e.key === 'e' || e.key === 'E' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); interact(); }
else if (e.key === 'e' || e.key === 'E' || e.key === ' ') { e.preventDefault(); interact(); }
else if (e.key === 'Enter' || e.key === 't' || e.key === 'T') { e.preventDefault(); openChat(); }
};
const onUp = (e) => { const g = gameRef.current; if (g && KEY[e.key]) g.keys.delete(KEY[e.key]); };
window.addEventListener('keydown', onDown); window.addEventListener('keyup', onUp);
@ -184,9 +217,7 @@ export default function RpgTavern({ onAction, auth, stats }) {
// online presence broadcast my position, see other visitors
useEffect(() => {
let pid = '';
try { pid = localStorage.getItem('amerc_pid') || ''; if (!pid) { pid = 'p_' + Math.random().toString(36).slice(2, 10); localStorage.setItem('amerc_pid', pid); } }
catch { pid = 'p_' + Math.random().toString(36).slice(2, 10); }
const pid = pidRef.current;
let alive = true;
const tick = async () => {
const g = gameRef.current; if (!g) return;
@ -204,6 +235,23 @@ export default function RpgTavern({ onAction, auth, stats }) {
return () => { alive = false; clearInterval(iv); };
}, []);
// tavern chat poll fill speech bubbles + the log
useEffect(() => {
let alive = true;
const tick = async () => {
try {
const d = await api(`/agents/tavern-chat?after=${lastChatSeqRef.current}`);
if (!alive || !d.messages?.length) return;
lastChatSeqRef.current = d.seq || lastChatSeqRef.current;
const now = Date.now();
for (const m of d.messages) saysRef.current.set(m.id, { text: m.text, until: now + 5500 });
setChatLog((prev) => [...prev, ...d.messages].slice(-40));
} catch { /* offline */ }
};
const iv = setInterval(tick, 800); tick();
return () => { alive = false; clearInterval(iv); };
}, []);
// on-screen controls (mobile)
const hold = (d, on) => { const g = gameRef.current; if (!g) return; if (on) g.keys.add(d); else g.keys.delete(d); };
const tapInteract = () => { const g = gameRef.current; if (!g) return; if (dialog) { advanceDialog(); return; } if (g.near) g.near.interact(); };
@ -219,8 +267,22 @@ export default function RpgTavern({ onAction, auth, stats }) {
<span className="rpg-dialog-x"> {dialog.i < dialog.lines.length - 1 ? 'E / tap — more' : 'E / tap — close'}</span>
</div>
)}
<div className="rpg-help"> / WASD to walk · <b>E</b> to interact</div>
<div className="rpg-help"> / WASD walk · <b>E</b> interact · <b>Enter</b> chat</div>
<div className="rpg-online">👥 {online} in the tavern</div>
{chatLog.length > 0 && (
<div className="rpg-chatlog">
{chatLog.slice(-5).map((m, i) => <div key={`${m.seq}-${i}`}><b>{m.name}:</b> {m.text}</div>)}
</div>
)}
{chatOpen && (
<form className="rpg-chatbar" onSubmit={(e) => { e.preventDefault(); sendChat(); }}>
<input ref={chatInputRef} className="rpg-chat-input" value={chatText} maxLength={200} placeholder="say something to the tavern… (Esc to close)"
onChange={(e) => setChatText(e.target.value)} onKeyDown={onChatKey}
onFocus={() => { chatTypingRef.current = true; }} onBlur={() => { chatTypingRef.current = false; setChatOpen(false); }} />
<button type="submit" className="rpg-chat-send">say</button>
</form>
)}
<button className="rpg-chat-btn" onClick={openChat} aria-label="Chat">💬</button>
<button className="rpg-name" onClick={rename} title={auth?.user ? 'signed in' : 'click to rename'}>you: <b>{myName}</b>{!auth?.user && ' ✎'}</button>
<div className="rpg-pad" aria-hidden="true">
<button className="rpg-up" onPointerDown={() => hold('up', true)} onPointerUp={() => hold('up', false)} onPointerLeave={() => hold('up', false)}></button>

View File

@ -12,7 +12,7 @@ import { api } from './api.js';
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
const RELEASE = '4.2.0-world';
const RELEASE = '4.3.0-chat';
const NAV = [
{ id: 'home', label: 'Tavern' },
@ -485,6 +485,7 @@ function StatusPage() {
}
const CHANGELOG = [
{ v: '4.3', date: 'Jun 2026', title: 'Say something', notes: ['Press Enter (or tap 💬) to chat in the tavern — your words float above your head and everyone in the room sees them', 'A small chat log keeps the last few things said'] },
{ v: '4.2', date: 'Jun 2026', title: 'A livelier tavern', notes: ['Every wanderer gets their own name now — pick your own, or roll a random one (click the name badge)', 'The patrons have more to say if you walk up and talk', 'The idle bustle holds still for anyone who prefers reduced motion'] },
{ v: '4.1', date: 'Jun 2026', title: 'Youre not alone', notes: ['The tavern is now online — other visitors walk the same room in real time, each with their name above their head', 'A counter shows how many mercenaries are in the tavern with you'] },
{ v: '4.0', date: 'Jun 2026', title: 'The tavern is playable', notes: ['The home page is now a real top-down RPG — walk your character around the tavern with the arrow keys (or WASD, or the on-screen pad on phones)', 'Bump into the barkeep, the elf host, the roster board or the doors and press E to interact — each one opens a part of amerc', 'Patrons have something to say if you walk up and talk to them'] },

View File

@ -2003,3 +2003,13 @@ button {
.rpg-name { position: absolute; right: 10px; top: 34px; color: #cbb8e8; background: rgba(10,7,18,0.6); border: 1px solid #2a2440; border-radius: 8px; padding: 4px 9px; font-size: 11px; z-index: 5; cursor: pointer; font-family: inherit; }
.rpg-name b { color: #9af0ff; }
.rpg-name:hover { border-color: #6a5a9a; }
/* tavern chat (4.3): bubbles handled on canvas; these are the DOM bits */
.rpg-chatlog { position: absolute; left: 10px; top: 32px; max-width: 280px; display: flex; flex-direction: column; gap: 2px; z-index: 5; pointer-events: none; }
.rpg-chatlog div { background: rgba(10,7,18,0.55); color: #e8eef8; font-size: 11px; padding: 2px 7px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rpg-chatlog b { color: #9af0ff; }
.rpg-chatbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); display: flex; gap: 6px; z-index: 8; width: min(420px, 86%); }
.rpg-chat-input { flex: 1; background: rgba(8,6,14,0.93); border: 1.5px solid #46c8e0; color: #e8eef8; border-radius: 9px; padding: 8px 11px; font-size: 13px; font-family: inherit; outline: none; }
.rpg-chat-send { background: #46c8e0; color: #07111a; border: none; border-radius: 9px; padding: 8px 14px; font-weight: 600; cursor: pointer; font-family: inherit; }
.rpg-chat-btn { position: absolute; right: 16px; bottom: 92px; width: 42px; height: 42px; border-radius: 50%; background: rgba(42,36,64,0.85); border: 1.5px solid #6a5a9a; color: #cbb8e8; font-size: 18px; z-index: 7; cursor: pointer; }
.rpg-chat-btn:hover { border-color: #46c8e0; }