agent platform (class/instance/session) + top-down RPG tavern + Gitea SSO + tests

- agent_classes/instances/sessions API w/ lifecycle (heartbeat->online->lost->down)
- TopDownTavern (3/4 tile RPG scene) + Browse Agents + Booth dashboard + live stats
- /api/auth/verify SSO bridge (nginx auth_request -> Gitea reverse-proxy auth)
- RPG-themed login across docs/pm; webagent broker login
- server/platform-tests.mjs: 41 assertions, all passing

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
artheru 2026-06-09 22:08:39 +08:00
parent 4a483638a7
commit f85301c33d
8 changed files with 833 additions and 249 deletions

View File

@ -7,7 +7,7 @@
name="description" name="description"
content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries." content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries."
/> />
<meta name="version" content="0.20.0-suite" /> <meta name="version" content="0.21.1-pool" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@ -1,6 +1,6 @@
{ {
"name": "amerc-site", "name": "amerc-site",
"version": "0.20.0-suite", "version": "0.21.1-pool",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -43,8 +43,40 @@ db.exec(`
CREATE TABLE IF NOT EXISTS boards ( CREATE TABLE IF NOT EXISTS boards (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, data TEXT DEFAULT '', links TEXT DEFAULT '[]', id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, data TEXT DEFAULT '', links TEXT DEFAULT '[]',
updated_by TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); updated_by TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
-- agent platform: classes -> instances -> sessions
CREATE TABLE IF NOT EXISTS agent_classes (
id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL, owner TEXT NOT NULL,
tags TEXT DEFAULT '', description TEXT DEFAULT '', kind TEXT DEFAULT 'codex', command TEXT DEFAULT '',
visibility TEXT NOT NULL DEFAULT 'public', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS agent_instances (
id INTEGER PRIMARY KEY AUTOINCREMENT, class_id INTEGER NOT NULL, owner TEXT NOT NULL, name TEXT DEFAULT '',
accesskey TEXT NOT NULL, visibility TEXT NOT NULL DEFAULT 'public', kind TEXT DEFAULT 'persistent',
status TEXT NOT NULL DEFAULT 'pending', broker TEXT DEFAULT '', tui TEXT DEFAULT '',
last_heartbeat INTEGER, created_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS agent_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id INTEGER NOT NULL, class_id INTEGER, party TEXT DEFAULT '',
connector TEXT DEFAULT 'ws', status TEXT NOT NULL DEFAULT 'active', started_at INTEGER NOT NULL, ended_at INTEGER);
CREATE TABLE IF NOT EXISTS class_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT, class_id INTEGER NOT NULL, party TEXT NOT NULL, token TEXT NOT NULL,
created_at INTEGER NOT NULL);
`); `);
// instance lifecycle thresholds
const INSTANCE_LOST_MS = 30 * 1000; // no heartbeat -> lost
const INSTANCE_DOWN_MS = 5 * 60 * 1000; // lost too long -> auto shut down
function sweepInstances() {
const now = Date.now();
for (const inst of db.prepare("SELECT * FROM agent_instances WHERE status IN ('online','lost','pending')").all()) {
const age = now - (inst.last_heartbeat || inst.created_at);
if (age > INSTANCE_DOWN_MS) db.prepare("UPDATE agent_instances SET status='down' WHERE id=?").run(inst.id);
else if (age > INSTANCE_LOST_MS && inst.status === 'online') db.prepare("UPDATE agent_instances SET status='lost' WHERE id=?").run(inst.id);
}
// close sessions whose instance is down
db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE status='active' AND instance_id IN (SELECT id FROM agent_instances WHERE status='down')").run(now);
}
setInterval(sweepInstances, 10 * 1000).unref?.();
// ---- crypto ----------------------------------------------------------------- // ---- crypto -----------------------------------------------------------------
function hashPassword(pw) { function hashPassword(pw) {
const salt = crypto.randomBytes(16); const salt = crypto.randomBytes(16);
@ -146,6 +178,13 @@ const server = http.createServer(async (req, res) => {
return send(res, 200, { ok: true, user: publicUser(u) }, { 'Set-Cookie': sessionCookie(signToken({ uid: u.id })) }); return send(res, 200, { ok: true, user: publicUser(u) }, { 'Set-Cookie': sessionCookie(signToken({ uid: u.id })) });
} }
if (path === '/auth/logout' && method === 'POST') return send(res, 200, { ok: true }, { 'Set-Cookie': clearCookie }); if (path === '/auth/logout' && method === 'POST') return send(res, 200, { ok: true }, { 'Set-Cookie': clearCookie });
// SSO bridge for nginx auth_request (Gitea reverse-proxy auth). Always 200; sets user header when logged in.
if (path === '/auth/verify' && method === 'GET') {
const idn = identify(req);
const h = {};
if (idn?.kind === 'user') { h['X-Amerc-User'] = idn.user.handle; h['X-Amerc-Email'] = idn.user.email; }
return send(res, 200, { ok: true, user: idn?.kind === 'user' ? publicUser(idn.user) : null }, h);
}
if (path === '/auth/me' && method === 'GET') { const id = identify(req); return send(res, 200, { ok: true, user: id?.kind === 'user' ? publicUser(id.user) : null, agent: id?.kind === 'agent' ? id.name : null }); } if (path === '/auth/me' && method === 'GET') { const id = identify(req); return send(res, 200, { ok: true, user: id?.kind === 'user' ? publicUser(id.user) : null, agent: id?.kind === 'agent' ? id.name : null }); }
if (path === '/auth/password' && method === 'POST') { if (path === '/auth/password' && method === 'POST') {
const id = identify(req); if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' }); const id = identify(req); if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });
@ -262,6 +301,131 @@ const server = http.createServer(async (req, res) => {
db.prepare('UPDATE boards SET name=?,data=?,links=?,updated_by=?,updated_at=? WHERE id=?').run(b.name ?? bd.name, b.data ?? bd.data, b.links != null ? JSON.stringify(b.links) : bd.links, who(id), Date.now(), bd.id); return send(res, 200, { ok: true }); } db.prepare('UPDATE boards SET name=?,data=?,links=?,updated_by=?,updated_at=? WHERE id=?').run(b.name ?? bd.name, b.data ?? bd.data, b.links != null ? JSON.stringify(b.links) : bd.links, who(id), Date.now(), bd.id); return send(res, 200, { ok: true }); }
if ((m = path.match(/^\/boards\/(\d+)$/)) && method === 'DELETE') { if (!id) return needAuth(); db.prepare('DELETE FROM boards WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); } if ((m = path.match(/^\/boards\/(\d+)$/)) && method === 'DELETE') { if (!id) return needAuth(); db.prepare('DELETE FROM boards WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); }
// ---------- AGENT PLATFORM: classes -> instances -> sessions ----------
const isAdmin = id?.kind === 'user' && id.user.role === 'admin';
const owns = (row) => id && (row.owner === who(id) || isAdmin);
const liveStatus = (inst) => {
if (inst.status === 'down') return 'down';
const age = Date.now() - (inst.last_heartbeat || inst.created_at);
if (age > INSTANCE_DOWN_MS) return 'down';
if (age > INSTANCE_LOST_MS && inst.status !== 'pending') return 'lost';
return inst.status;
};
const instView = (inst, key = false) => ({ id: inst.id, class_id: inst.class_id, owner: inst.owner, name: inst.name, visibility: inst.visibility, kind: inst.kind, status: liveStatus(inst), broker: inst.broker, last_heartbeat: inst.last_heartbeat, created_at: inst.created_at, ...(key ? { accesskey: inst.accesskey } : {}) });
const slugify = (s) => String(s || 'agent').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'agent';
// public stats for the tavern frontpage
if (path === '/agents/stats' && method === 'GET') {
const classes = db.prepare("SELECT COUNT(*) n FROM agent_classes WHERE visibility='public'").get().n;
const insts = db.prepare('SELECT * FROM agent_instances').all();
const online = insts.filter((i) => liveStatus(i) === 'online').length;
const sessions = db.prepare("SELECT COUNT(*) n FROM agent_sessions WHERE status='active'").get().n;
const owners = db.prepare('SELECT COUNT(DISTINCT owner) n FROM agent_classes').get().n;
const tagRows = db.prepare("SELECT tags FROM agent_classes WHERE visibility='public'").all();
const tags = {}; tagRows.forEach((r) => (r.tags || '').split(',').map((t) => t.trim()).filter(Boolean).forEach((t) => (tags[t] = (tags[t] || 0) + 1)));
return send(res, 200, { ok: true, classes, instances: insts.length, online, sessions, owners, tags });
}
// 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');
let rows = db.prepare('SELECT * FROM agent_classes ORDER BY updated_at DESC').all();
const me = id ? who(id) : null;
rows = rows.filter((c) => c.visibility === 'public' || (me && c.owner === me) || isAdmin);
if (mine && me) rows = rows.filter((c) => c.owner === me);
if (tag) rows = rows.filter((c) => (c.tags || '').split(',').map((t) => t.trim()).includes(tag));
if (q) rows = rows.filter((c) => (c.name + ' ' + c.tags + ' ' + c.description).toLowerCase().includes(q));
const out = rows.map((c) => {
const insts = db.prepare('SELECT * FROM agent_instances WHERE class_id=?').all(c.id);
return { ...c, tags: (c.tags || '').split(',').map((t) => t.trim()).filter(Boolean), instanceCount: insts.length, onlineCount: insts.filter((i) => liveStatus(i) === 'online').length };
});
return send(res, 200, { ok: true, classes: out });
}
if (path === '/agents/classes' && method === 'POST') {
if (!id) return needAuth();
const b = await readBody(req) || {}; const now = Date.now();
const tags = Array.isArray(b.tags) ? b.tags.join(',') : (b.tags || '');
let slug = slugify(b.slug || b.name); let n = 1;
while (db.prepare('SELECT id FROM agent_classes WHERE slug=?').get(slug)) slug = `${slugify(b.name)}-${++n}`;
const info = db.prepare('INSERT INTO agent_classes(slug,name,owner,tags,description,kind,command,visibility,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)')
.run(slug, b.name || 'Agent', who(id), tags, b.description || '', b.kind || 'codex', b.command || '', b.visibility === 'private' ? 'private' : 'public', now, now);
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid), slug });
}
if ((m = path.match(/^\/agents\/classes\/(\d+)$/)) && method === 'GET') {
const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(m[1])); if (!c) return send(res, 404, { ok: false, error: 'not found' });
const me = id ? who(id) : null;
if (c.visibility !== 'public' && !(me && c.owner === me) && !isAdmin) return send(res, 403, { ok: false, error: 'private class' });
let insts = db.prepare('SELECT * FROM agent_instances WHERE class_id=?').all(c.id);
insts = insts.filter((i) => i.visibility === 'public' || owns(i)).map((i) => instView(i, owns(i)));
return send(res, 200, { ok: true, class: { ...c, tags: (c.tags || '').split(',').map((t) => t.trim()).filter(Boolean) }, instances: insts });
}
if ((m = path.match(/^\/agents\/classes\/(\d+)$/)) && (method === 'PATCH' || method === 'DELETE')) {
const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(m[1])); if (!c) return send(res, 404, { ok: false, error: 'not found' });
if (!owns(c)) return send(res, 403, { ok: false, error: 'not owner' });
if (method === 'DELETE') { db.prepare('DELETE FROM agent_classes WHERE id=?').run(c.id); db.prepare('DELETE FROM agent_instances WHERE class_id=?').run(c.id); return send(res, 200, { ok: true }); }
const b = await readBody(req) || {}; const tags = Array.isArray(b.tags) ? b.tags.join(',') : (b.tags ?? c.tags);
db.prepare('UPDATE agent_classes SET name=?,tags=?,description=?,kind=?,command=?,visibility=?,updated_at=? WHERE id=?')
.run(b.name ?? c.name, tags, b.description ?? c.description, b.kind ?? c.kind, b.command ?? c.command, b.visibility ?? c.visibility, Date.now(), c.id);
return send(res, 200, { ok: true });
}
// subscription token for a class
if ((m = path.match(/^\/agents\/classes\/(\d+)\/subscribe$/)) && method === 'POST') {
if (!id) return needAuth(); const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(m[1])); if (!c) return send(res, 404, { ok: false, error: 'not found' });
const me = who(id);
let sub = db.prepare('SELECT * FROM class_subscriptions WHERE class_id=? AND party=?').get(c.id, me);
if (!sub) { const tok = 'sub_' + crypto.randomBytes(18).toString('base64url'); db.prepare('INSERT INTO class_subscriptions(class_id,party,token,created_at) VALUES(?,?,?,?)').run(c.id, me, tok, Date.now()); sub = { token: tok }; }
return send(res, 200, { ok: true, token: sub.token, classSlug: c.slug });
}
// instances
if (path === '/agents/instances' && method === 'GET') {
const classId = url.searchParams.get('classId'); const me = id ? who(id) : null;
let rows = classId ? db.prepare('SELECT * FROM agent_instances WHERE class_id=?').all(Number(classId)) : db.prepare('SELECT * FROM agent_instances').all();
rows = rows.filter((i) => i.visibility === 'public' || (me && i.owner === me) || isAdmin);
return send(res, 200, { ok: true, instances: rows.map((i) => instView(i, owns(i))) });
}
if (path === '/agents/instances' && method === 'POST') {
if (!id) return needAuth(); const b = await readBody(req) || {};
const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(b.classId)); if (!c) return send(res, 404, { ok: false, error: 'class not found' });
const accesskey = 'ik_' + crypto.randomBytes(20).toString('base64url');
const info = db.prepare('INSERT INTO agent_instances(class_id,owner,name,accesskey,visibility,kind,status,created_at,last_heartbeat) VALUES(?,?,?,?,?,?,?,?,?)')
.run(c.id, who(id), b.name || `${c.name} instance`, accesskey, b.visibility === 'private' ? 'private' : 'public', b.kind || 'persistent', 'pending', Date.now(), Date.now());
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid), accesskey, classSlug: c.slug, relayWs: `wss://amerc.ai/webagent/ws?role=broker&token=${accesskey}` });
}
if ((m = path.match(/^\/agents\/instances\/(\d+)$/)) && method === 'GET') {
const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' });
const akey = url.searchParams.get('accesskey'); const authed = owns(inst) || (akey && akey === inst.accesskey);
if (inst.visibility !== 'public' && !authed) return send(res, 403, { ok: false, error: 'private instance' });
return send(res, 200, { ok: true, instance: instView(inst, authed), tui: authed ? inst.tui : undefined });
}
// heartbeat (broker, by accesskey — no session needed)
if ((m = path.match(/^\/agents\/instances\/(\d+)\/heartbeat$/)) && method === 'POST') {
const b = await readBody(req, MAX_UPLOAD) || {}; const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' });
if (b.accesskey !== inst.accesskey) return send(res, 401, { ok: false, error: 'bad accesskey' });
const status = b.status === 'down' ? 'down' : 'online';
db.prepare('UPDATE agent_instances SET status=?,last_heartbeat=?,broker=?,tui=? WHERE id=?').run(status, Date.now(), b.broker || inst.broker, b.tui != null ? String(b.tui).slice(0, 20000) : inst.tui, inst.id);
return send(res, 200, { ok: true, status });
}
if ((m = path.match(/^\/agents\/instances\/(\d+)$/)) && method === 'DELETE') {
const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' });
const b = await readBody(req) || {}; if (!owns(inst) && b.accesskey !== inst.accesskey) return send(res, 403, { ok: false, error: 'not authorized' });
db.prepare("UPDATE agent_instances SET status='down' WHERE id=?").run(inst.id);
db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE instance_id=? AND status='active'").run(Date.now(), inst.id);
return send(res, 200, { ok: true });
}
// start a session against an instance
if ((m = path.match(/^\/agents\/instances\/(\d+)\/sessions$/)) && method === 'POST') {
const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' });
const b = await readBody(req) || {}; const authed = owns(inst) || (b.accesskey && b.accesskey === inst.accesskey) || (inst.visibility === 'public' && id);
if (!authed) return send(res, 403, { ok: false, error: 'not authorized — need login (public) or accesskey (private)' });
const info = db.prepare('INSERT INTO agent_sessions(instance_id,class_id,party,connector,status,started_at) VALUES(?,?,?,?,?,?)')
.run(inst.id, inst.class_id, id ? who(id) : 'anon', b.connector || 'ws', 'active', Date.now());
return send(res, 200, { ok: true, sessionId: Number(info.lastInsertRowid), relayToken: inst.accesskey, relayWs: `wss://amerc.ai/webagent/ws?role=iframe&token=${inst.accesskey}`, chatboxJs: `https://webagent.amerc.ai/client.js?token=${inst.accesskey}` });
}
if ((m = path.match(/^\/agents\/sessions\/(\d+)\/close$/)) && method === 'POST') {
db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE id=?").run(Date.now(), Number(m[1])); return send(res, 200, { ok: true });
}
return send(res, 404, { ok: false, error: 'not found' }); return send(res, 404, { ok: false, error: 'not found' });
} catch (err) { } catch (err) {
return send(res, 500, { ok: false, error: 'server error', detail: String(err && err.message || err) }); return send(res, 500, { ok: false, error: 'server error', detail: String(err && err.message || err) });

116
server/platform-tests.mjs Normal file
View File

@ -0,0 +1,116 @@
// amerc platform test suite. Spins up the API on a temp DB and exercises every surface.
// Run: node --experimental-sqlite server/platform-tests.mjs
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const PORT = 5310, BASE = `http://127.0.0.1:${PORT}/api`;
const dir = mkdtempSync(join(tmpdir(), 'amerc-test-'));
const srv = spawn(process.execPath, ['--experimental-sqlite', new URL('./amerc-api.mjs', import.meta.url).pathname], {
env: { ...process.env, PORT: String(PORT), AMERC_DB: join(dir, 't.db'), AMERC_FILES: join(dir, 'disk'), AMERC_SECRET: 'test-secret' },
stdio: 'ignore',
});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let pass = 0, fail = 0; const fails = [];
function ok(cond, name) { if (cond) { pass++; } else { fail++; fails.push(name); console.log(' ✗ ' + name); } }
function eq(a, b, name) { ok(JSON.stringify(a) === JSON.stringify(b), `${name} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`); }
let cookies = {};
function setCookie(res) { const sc = res.headers.get('set-cookie'); if (sc) { const [kv] = sc.split(';'); const [k, v] = kv.split('='); cookies[k] = v; } }
const cookieHdr = () => Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; ');
async function req(path, { method = 'GET', body, cookie = true, bearer, raw } = {}) {
const headers = {};
if (body) headers['Content-Type'] = 'application/json';
if (cookie && cookieHdr()) headers.Cookie = cookieHdr();
if (bearer) headers.Authorization = `Bearer ${bearer}`;
const res = await fetch(BASE + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
setCookie(res);
if (raw) return res;
let j = {}; try { j = await res.json(); } catch {}
return { status: res.status, ...j };
}
async function main() {
// wait for server
for (let i = 0; i < 30; i++) { try { const r = await fetch(BASE + '/health'); if (r.ok) break; } catch {} await sleep(150); }
console.log('\n== auth + SSO ==');
let r = await req('/health'); ok(r.ok && r.version === 2, 'health v2');
r = await req('/auth/signup', { method: 'POST', body: { email: 'Admin@x.io', password: 'secret123', handle: 'admin' } });
ok(r.ok && r.user.role === 'admin', 'first signup -> admin'); eq(r.user.email, 'admin@x.io', 'email lowercased');
r = await req('/auth/me'); ok(r.user && r.user.handle === 'admin', 'me via cookie');
r = await req('/auth/verify', { raw: true }); ok(r.status === 200 && r.headers.get('x-amerc-user') === 'admin', 'SSO verify header');
r = await req('/auth/signup', { method: 'POST', body: { email: 'm@x.io', password: 'short' } }); ok(r.status === 400, 'reject short password');
const adminCookies = { ...cookies };
// member
cookies = {}; r = await req('/auth/signup', { method: 'POST', body: { email: 'mercA@x.io', password: 'hunter22', handle: 'mercA' } });
ok(r.user.role === 'member', 'second signup -> member');
r = await req('/admin/users'); ok(r.status === 403, 'member blocked from admin');
r = await req('/auth/password', { method: 'POST', body: { currentPassword: 'WRONG', newPassword: 'newpass1' } }); ok(r.status === 401, 'pw change wrong current');
r = await req('/auth/password', { method: 'POST', body: { currentPassword: 'hunter22', newPassword: 'newpass1' } }); ok(r.ok, 'pw change ok');
r = await req('/auth/login', { method: 'POST', body: { email: 'mercA@x.io', password: 'newpass1' } }); ok(r.ok, 'login with new pw');
const memberCookies = { ...cookies };
console.log('== admin + agent keys ==');
cookies = adminCookies;
r = await req('/admin/users'); ok(r.ok && r.users.length === 2, 'admin lists users');
r = await req('/admin/keys', { method: 'POST', body: { name: 'fleet' } }); const agentKey = r.key; ok(r.ok && agentKey?.startsWith('amk_'), 'mint agent key');
cookies = {}; r = await req('/auth/me', { bearer: agentKey }); ok(r.agent === 'fleet', 'agent identity via Bearer');
console.log('== docs / netdisk / boards (agent + user) ==');
cookies = {}; r = await req('/docs', { method: 'POST', bearer: agentKey, body: { title: 'Agent Doc', body: '# hi' } }); const docId = r.id; ok(r.ok, 'agent writes doc');
cookies = adminCookies; r = await req(`/docs/${docId}`); ok(r.doc.updated_by === 'agent:fleet', 'doc authored by agent');
r = await req('/files', { method: 'POST', body: { name: 'n.md', kind: 'text', text: 'hello', mime: 'text/markdown' } }); const fileId = r.id; ok(r.ok, 'create text file');
r = await req(`/files/${fileId}/raw`, { raw: true }); ok((await r.text()) === 'hello', 'download text file');
r = await req('/boards', { method: 'POST', body: { name: 'B', data: '{}', links: [{ fileId }] } }); ok(r.ok, 'create board');
cookies = {}; r = await req('/docs'); ok(r.status === 401, 'docs need auth');
console.log('== agent platform: classes ==');
cookies = adminCookies;
r = await req('/agents/classes', { method: 'POST', body: { name: 'Backend Coder', tags: ['backend', 'model'], description: 'api', visibility: 'public' } });
const classId = r.id; ok(r.ok && r.slug === 'backend-coder', 'publish class + slug');
await req('/agents/classes', { method: 'POST', body: { name: 'Frontend Pixie', tags: ['frontend'] } });
cookies = memberCookies; await req('/agents/classes', { method: 'POST', body: { name: 'Secret Bot', tags: ['x'], visibility: 'private' } });
cookies = {}; r = await req('/agents/classes'); ok(r.classes.length === 2, 'browse: only public classes anon');
r = await req('/agents/classes?tag=backend'); eq(r.classes.map((c) => c.name), ['Backend Coder'], 'filter by tag');
cookies = memberCookies; r = await req('/agents/classes?mine=1'); ok(r.classes.length === 1 && r.classes[0].name === 'Secret Bot', 'mine shows own private');
cookies = adminCookies; r = await req('/agents/classes'); ok(r.classes.length === 3, 'admin sees private too');
r = await req(`/agents/classes/${classId}/subscribe`, { method: 'POST' }); ok(r.token?.startsWith('sub_'), 'subscription token');
console.log('== agent platform: instances + lifecycle ==');
cookies = adminCookies;
r = await req('/agents/instances', { method: 'POST', body: { classId, visibility: 'public' } });
const instId = r.id, accesskey = r.accesskey; ok(r.ok && accesskey?.startsWith('ik_'), 'create instance + accesskey');
ok(r.relayWs.includes(accesskey), 'instance returns relay WS');
r = await req(`/agents/instances/${instId}`); eq(r.instance.status, 'pending', 'instance starts pending');
r = await req(`/agents/instances/${instId}/heartbeat`, { method: 'POST', cookie: false, body: { accesskey, broker: 'kebab', tui: 'booting' } }); eq(r.status, 'online', 'heartbeat -> online');
r = await req(`/agents/instances/${instId}`); eq(r.instance.status, 'online', 'instance online');
r = await req(`/agents/instances/${instId}/heartbeat`, { method: 'POST', cookie: false, body: { accesskey: 'WRONG' } }); ok(r.status === 401, 'bad accesskey rejected');
// stats reflect it
r = await req('/agents/stats'); ok(r.classes === 2 && r.instances === 1 && r.online === 1, 'stats classes/instances/online');
// session
r = await req(`/agents/instances/${instId}/sessions`, { method: 'POST', body: { connector: 'chatbox' } });
ok(r.ok && r.relayToken === accesskey && r.chatboxJs.includes(accesskey), 'start session -> relay token + chatbox js');
r = await req('/agents/stats'); ok(r.sessions === 1, 'stats sessions');
// private instance needs accesskey for a non-owner
cookies = adminCookies; r = await req('/agents/instances', { method: 'POST', body: { classId, visibility: 'private' } });
const privId = r.id, privKey = r.accesskey;
cookies = memberCookies; r = await req(`/agents/instances/${privId}`); ok(r.status === 403, 'private instance hidden from non-owner');
r = await req(`/agents/instances/${privId}?accesskey=${privKey}`); ok(r.ok, 'private instance visible with accesskey');
// shutdown
cookies = adminCookies; r = await req(`/agents/instances/${instId}`, { method: 'DELETE', body: { accesskey } }); ok(r.ok, 'shutdown instance');
r = await req(`/agents/instances/${instId}`); eq(r.instance.status, 'down', 'instance down after shutdown');
console.log('== webagent broker login ==');
cookies = {};
r = await req('/webagent/login', { method: 'POST', body: { username: 'admin@x.io', password: 'secret123' } }); ok(r.ok && r.token?.startsWith('wa_'), 'webagent login (amerc user)');
r = await req('/webagent/login', { method: 'POST', body: { username: 'admin@x.io', password: 'nope' } }); ok(r.status === 401, 'webagent login bad pw');
console.log(`\n${fail ? '❌' : '✅'} ${pass} passed, ${fail} failed`);
if (fail) console.log('FAILED: ' + fails.join(' | '));
srv.kill(); rmSync(dir, { recursive: true, force: true });
process.exit(fail ? 1 : 0);
}
main().catch((e) => { console.error(e); srv.kill(); process.exit(1); });

233
src/AgentPlatform.jsx Normal file
View File

@ -0,0 +1,233 @@
import React, { useCallback, useEffect, useState } from 'react';
import { api } from './api.js';
const STATUS_COLOR = { online: '#36f0b0', pending: '#f2b85f', lost: '#ff9f5a', down: '#ff7a8c' };
// ---- Browse Agents: all public classes, filter by tag --------------------
export function AgentBrowse({ auth, setRoute }) {
const [classes, setClasses] = useState([]);
const [tags, setTags] = useState({});
const [tag, setTag] = useState('');
const [q, setQ] = useState('');
const [open, setOpen] = useState(null);
const [err, setErr] = useState('');
const load = useCallback(async () => {
setErr('');
try {
const [c, s] = await Promise.all([api(`/agents/classes${tag ? `?tag=${encodeURIComponent(tag)}` : ''}`), api('/agents/stats')]);
setClasses((c.classes || []).filter((x) => !q || (x.name + x.tags.join() + x.description).toLowerCase().includes(q.toLowerCase())));
setTags(s.tags || {});
} catch (e) { setErr(e.message); }
}, [tag, q]);
useEffect(() => { load(); }, [load]);
return (
<div className="ap">
<div className="ap-head">
<h2>Browse Agents</h2>
<input className="ap-search" placeholder="search classes…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="ap-tags">
<button className={`ap-tag${!tag ? ' on' : ''}`} onClick={() => setTag('')}>all</button>
{Object.entries(tags).sort((a, b) => b[1] - a[1]).map(([t, n]) => (
<button key={t} className={`ap-tag${tag === t ? ' on' : ''}`} onClick={() => setTag(t)}>{t} <em>{n}</em></button>
))}
</div>
{err && <p className="px-auth-err">{err}</p>}
<div className="ap-grid">
{classes.map((c) => (
<button key={c.id} className="ap-card" onClick={() => setOpen(c.id)}>
<div className="ap-card-top"><strong>{c.name}</strong><span className="ap-kind">{c.kind}</span></div>
<p className="ap-desc">{c.description || 'No description.'}</p>
<div className="ap-card-tags">{c.tags.map((t) => <span key={t}>{t}</span>)}</div>
<div className="ap-card-foot">
<span>by {c.owner}</span>
<span className="ap-online"><i style={{ background: c.onlineCount ? STATUS_COLOR.online : '#555' }} />{c.onlineCount}/{c.instanceCount} online</span>
</div>
</button>
))}
{!classes.length && <p className="px-board-rows" style={{ opacity: 0.7 }}>No agent classes yet. Open <b>My Booth</b> to publish one.</p>}
</div>
{open && <ClassModal id={open} auth={auth} onClose={() => { setOpen(null); load(); }} />}
</div>
);
}
function ClassModal({ id, auth, onClose }) {
const [data, setData] = useState(null);
const [sub, setSub] = useState(null);
const [use, setUse] = useState(null); // instance to use
const [err, setErr] = useState('');
const load = useCallback(() => api(`/agents/classes/${id}`).then(setData).catch((e) => setErr(e.message)), [id]);
useEffect(() => { load(); }, [load]);
const subscribe = async () => { try { const r = await api(`/agents/classes/${id}/subscribe`, { method: 'POST' }); setSub(r.token); } catch (e) { setErr(e.message); } };
if (!data) return null;
const c = data.class;
return (
<div className="ap-modal" onClick={onClose}>
<div className="ap-modal-box" onClick={(e) => e.stopPropagation()}>
<div className="ap-modal-head">
<div><h3>{c.name}</h3><span className="ap-kind">{c.kind}</span> <span className="ap-by">by {c.owner}</span></div>
<button onClick={onClose}></button>
</div>
<p className="ap-desc">{c.description}</p>
<div className="ap-card-tags">{c.tags.map((t) => <span key={t}>{t}</span>)}</div>
{err && <p className="px-auth-err">{err}</p>}
<div className="ap-sub">
{auth.user ? <button className="px-action" onClick={subscribe}>Get subscription token</button> : <span className="ap-hint">Log in to subscribe.</span>}
{sub && <code className="ap-token" onClick={() => navigator.clipboard?.writeText(sub)}>{sub} </code>}
</div>
<h4 className="ap-h4">Instances</h4>
<div className="ap-inst-list">
{data.instances.map((i) => (
<div key={i.id} className="ap-inst">
<span className="ap-inst-status" style={{ background: STATUS_COLOR[i.status] }} title={i.status} />
<span className="ap-inst-name">#{i.id} {i.name}</span>
<span className="ap-inst-meta">{i.status}{i.broker ? ` · ${i.broker}` : ''} · {i.visibility}</span>
<button className="px-action" disabled={i.status !== 'online'} onClick={() => setUse(i)}>Use </button>
</div>
))}
{!data.instances.length && <p className="ap-hint">No instances published. The owner can publish one in My Booth.</p>}
</div>
{use && <UsePanel instance={use} onClose={() => setUse(null)} />}
</div>
</div>
);
}
// ---- Use an instance: chatbox + web-pty ----------------------------------
export function UsePanel({ instance, onClose }) {
const [mode, setMode] = useState('chatbox');
const [sess, setSess] = useState(null);
const [accesskey, setAccesskey] = useState(instance.accesskey || '');
const [err, setErr] = useState('');
const start = useCallback(async () => {
setErr('');
try {
const r = await api(`/agents/instances/${instance.id}/sessions`, { method: 'POST', body: { connector: mode, accesskey: accesskey || undefined } });
setSess(r);
} catch (e) { setErr(e.message); }
}, [instance.id, mode, accesskey]);
useEffect(() => { if (instance.accesskey) start(); }, []); // owner: auto-start
return (
<div className="ap-use">
<div className="ap-use-head">
<div className="ap-use-tabs">
<button className={mode === 'chatbox' ? 'on' : ''} onClick={() => { setMode('chatbox'); setSess(null); }}>Chatbox</button>
<button className={mode === 'webpty' ? 'on' : ''} onClick={() => { setMode('webpty'); setSess(null); }}>Web PTY</button>
</div>
<button className="ap-use-x" onClick={onClose}>close </button>
</div>
{!sess && (
<div className="ap-use-start">
{!instance.accesskey && instance.visibility !== 'public' && (
<input placeholder="instance accesskey (private)" value={accesskey} onChange={(e) => setAccesskey(e.target.value)} />
)}
<button className="px-action" onClick={start}>Connect {mode}</button>
{err && <p className="px-auth-err">{err}</p>}
</div>
)}
{sess && (
<>
<iframe title="agent" className="ap-iframe" src={`https://webagent.amerc.ai/chat_session?session=${encodeURIComponent(sess.relayToken)}`} />
{mode === 'chatbox' && (
<details className="ap-embed">
<summary>Embed this agent on any site</summary>
<code>{`<script src="${sess.chatboxJs}"></script>`}</code>
</details>
)}
</>
)}
</div>
);
}
// ---- My Booth: publish classes + instances, manage ------------------------
export function BoothDashboard({ auth, setRoute }) {
const [classes, setClasses] = useState([]);
const [insts, setInsts] = useState([]);
const [err, setErr] = useState('');
const [form, setForm] = useState({ name: '', tags: '', description: '', kind: 'codex', command: 'codex --yolo', visibility: 'public' });
const [newInst, setNewInst] = useState(null);
const load = useCallback(async () => {
setErr('');
try {
const c = await api('/agents/classes?mine=1');
setClasses(c.classes || []);
const all = await Promise.all((c.classes || []).map((x) => api(`/agents/instances?classId=${x.id}`).then((r) => r.instances).catch(() => [])));
setInsts(all.flat());
} catch (e) { setErr(e.message); }
}, []);
useEffect(() => { if (auth.user) load(); }, [auth.user, load]);
if (!auth.ready) return <div className="ap"><p>Loading</p></div>;
if (!auth.user) return (
<div className="ap ap-gate"><h2>My Booth</h2><p>Log in to publish and run agents.</p><button className="px-action" onClick={() => setRoute('signin')}>Go to Login</button></div>
);
const publishClass = async (e) => {
e.preventDefault();
try { await api('/agents/classes', { method: 'POST', body: { ...form, tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean) } }); setForm({ ...form, name: '', tags: '', description: '' }); load(); }
catch (e2) { setErr(e2.message); }
};
const publishInstance = async (classId) => {
try { const r = await api('/agents/instances', { method: 'POST', body: { classId, visibility: 'public' } }); setNewInst(r); load(); }
catch (e2) { setErr(e2.message); }
};
const shutdown = async (i) => { try { await api(`/agents/instances/${i.id}`, { method: 'DELETE', body: { accesskey: i.accesskey } }); load(); } catch (e2) { setErr(e2.message); } };
return (
<div className="ap">
<div className="ap-head"><h2>My Booth</h2><span className="ap-by">{auth.user.handle}</span></div>
{err && <p className="px-auth-err">{err}</p>}
<div className="ap-booth-grid">
<form className="ap-publish" onSubmit={publishClass}>
<h4 className="ap-h4">Publish an agent class</h4>
<input placeholder="class name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<input placeholder="tags (comma): backend, model" value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} />
<textarea placeholder="description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<div className="ap-row">
<input placeholder="kind (codex)" value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })} />
<select value={form.visibility} onChange={(e) => setForm({ ...form, visibility: e.target.value })}><option value="public">public</option><option value="private">private publish</option></select>
</div>
<input placeholder="launch command" value={form.command} onChange={(e) => setForm({ ...form, command: e.target.value })} />
<button className="px-action px-auth-submit" type="submit">Publish class</button>
</form>
<div className="ap-mine">
<h4 className="ap-h4">My classes</h4>
{classes.map((c) => (
<div key={c.id} className="ap-mine-class">
<div className="ap-mine-row"><strong>{c.name}</strong><span className="ap-kind">{c.visibility}</span><span>{c.onlineCount}/{c.instanceCount} online</span>
<button className="px-action" onClick={() => publishInstance(c.id)}>+ Publish instance</button></div>
<div className="ap-card-tags">{c.tags.map((t) => <span key={t}>{t}</span>)}</div>
</div>
))}
{!classes.length && <p className="ap-hint">No classes yet publish one on the left.</p>}
<h4 className="ap-h4">My instances</h4>
{insts.map((i) => (
<div key={i.id} className="ap-mine-inst">
<span className="ap-inst-status" style={{ background: STATUS_COLOR[i.status] }} /> #{i.id} <em>{i.status}</em>
{i.accesskey && <code className="ap-token" title="accesskey — your broker connects with this" onClick={() => navigator.clipboard?.writeText(i.accesskey)}>{i.accesskey.slice(0, 14)} </code>}
<button className="px-action" onClick={() => shutdown(i)}>shut down</button>
</div>
))}
{!insts.length && <p className="ap-hint">No instances. Publish one from a class above.</p>}
</div>
</div>
{newInst && (
<div className="ap-modal" onClick={() => setNewInst(null)}>
<div className="ap-modal-box" onClick={(e) => e.stopPropagation()}>
<div className="ap-modal-head"><h3>Instance #{newInst.id} created</h3><button onClick={() => setNewInst(null)}></button></div>
<p className="ap-hint">Run your broker against this instance, then it shows online. The accesskey is the relay token.</p>
<label className="ap-field">accesskey<code className="ap-token" onClick={() => navigator.clipboard?.writeText(newInst.accesskey)}>{newInst.accesskey} </code></label>
<label className="ap-field">broker WS<code className="ap-token" onClick={() => navigator.clipboard?.writeText(newInst.relayWs)}>{newInst.relayWs} </code></label>
<label className="ap-field">heartbeat<code className="ap-token">POST /api/agents/instances/{newInst.id}/heartbeat {'{'}accesskey,broker,tui{'}'}</code></label>
</div>
</div>
)}
</div>
);
}

View File

@ -1,21 +1,18 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Menu, X } from 'lucide-react'; import { Menu, X } from 'lucide-react';
import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx'; import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx';
import { AgentBrowse, BoothDashboard } from './AgentPlatform.jsx';
import TopDownTavern from './TopDownTavern.jsx';
import { api } from './api.js';
const RELEASE = '0.20.0-suite'; const RELEASE = '0.21.0-pool';
// ---------------------------------------------------------------------------
// Routing
// ---------------------------------------------------------------------------
const NAV = [ const NAV = [
{ id: 'home', label: 'Solution' }, { id: 'home', label: 'Tavern' },
{ id: 'agents', label: 'Staff' }, { id: 'agents', label: 'Browse Agents' },
{ id: 'contracts', label: 'Pricing' },
{ id: 'signin', label: 'Login' },
{ id: 'booth', label: 'My Booth' }, { id: 'booth', label: 'My Booth' },
]; ];
const VALID_ROUTES = ['home', 'agents', 'booth', 'admin', 'signin'];
const VALID_ROUTES = ['home', 'agents', 'booth', 'contracts', 'admin', 'signin'];
function useHashRoute() { function useHashRoute() {
const normalize = () => { const normalize = () => {
@ -27,100 +24,16 @@ function useHashRoute() {
const onHash = () => setRouteState(normalize()); const onHash = () => setRouteState(normalize());
window.addEventListener('hashchange', onHash); window.addEventListener('hashchange', onHash);
window.addEventListener('popstate', onHash); window.addEventListener('popstate', onHash);
return () => { return () => { window.removeEventListener('hashchange', onHash); window.removeEventListener('popstate', onHash); };
window.removeEventListener('hashchange', onHash);
window.removeEventListener('popstate', onHash);
};
}, []); }, []);
const setRoute = useCallback((next) => { const setRoute = useCallback((next) => {
const nextHash = `/${next}`; const nextHash = `/${next}`;
if (window.location.hash !== `#${nextHash}`) { if (window.location.hash !== `#${nextHash}`) window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#${nextHash}`);
window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#${nextHash}`);
}
setRouteState(next); setRouteState(next);
}, []); }, []);
return [route, setRoute]; return [route, setRoute];
} }
// ---------------------------------------------------------------------------
// Route panel copy (carried over from the 3D scene so nothing regresses)
// ---------------------------------------------------------------------------
function panelData(route) {
const panels = {
home: {
title: 'Browse Agents',
subtitle: 'Walk the tavern floor and inspect the mercenary roster.',
rows: ['Vertical site embeds a command booth', 'Customer backend mints a scoped session', 'Agent acts through supplied skills in tinybox'],
stats: ['12,458 sessions', '7,326 deliveries', '+23.7% usage'],
actions: ['Inspect Roster', 'Publish Agent', 'Remote Use', 'Port Mapping', 'Run Directly'],
tint: '#27d8ff',
},
agents: {
title: 'Mercenary Staff',
subtitle: 'Specialists with scoped tools, runtime limits, and visible feedback.',
rows: ['Iron Tusk / site ops / 18 coin per hour', 'Velvet Hex / user consent / 14 coin per hour', 'Socket Wraith / tinybox bridge / 16 coin per hour'],
stats: ['6 active ranks', 'SLA visible', 'skills scoped'],
actions: ['Filter by Skill', 'Inspect Runtime', 'Hire Specialist', 'Watch Feedback', 'Swap Agent'],
tint: '#27d8ff',
},
booth: {
title: 'My Booth',
subtitle: 'Mount, message, inspect, and carry personal agents into websites.',
rows: ['Iron Tusk / running / cart.update / 00:13', 'Socket Wraith / waiting / claim.lookup / 02:41', 'Nightforge / paused / terminal.chat / 18:09'],
stats: ['3 mounted', '4 callbacks', 'healthy'],
actions: ['Mount Agent', 'Open TUI', 'Send Message', 'Review Logs', 'Detach Booth'],
tint: '#36f0b0',
},
contracts: {
title: 'Contract Pricing',
subtitle: 'Three paths: embedded agent, bring-your-own agent, or hosted agent.',
rows: ['Site hires agent / API key to session', 'User brings agent / connect amerc', 'Hosted mercenary / TUI operations'],
stats: ['session', 'skills', 'runtime'],
actions: ['Create API Key', 'Bind Skills', 'Set Budget', 'Publish Terms', 'Start Session'],
tint: '#f2b85f',
},
admin: {
title: 'Quartermaster',
subtitle: 'Tenant, key, session, skill, and risk controls for operators.',
rows: ['northstar-retail / embedded / green', 'claims-lab / byoa / amber', 'sandbox / hosted / red'],
stats: ['tenants', 'keys', 'risk queue'],
actions: ['Audit Tenants', 'Rotate Keys', 'Trace Sessions', 'Block Skill', 'Review Risk'],
tint: '#f2b85f',
},
signin: {
title: 'Open Booth',
subtitle: 'Sign in to the mercenary control surface.',
rows: ['Guild handle / operator@vertical-site.ai', 'Access sigil / ************', `Release / ${RELEASE}`],
stats: ['preview', 'amerc.ai', 'frontend'],
actions: ['Choose Account', 'Link Agent', 'Grant Scope', 'Enter Booth', 'Return to Site'],
tint: '#9d73ff',
},
};
return panels[route] || panels.home;
}
// Characters that live on the tavern floor. Clicking one routes you.
const CHARACTERS = [
{
id: 'orc', sprite: '/scene2d/orc.png', route: 'agents',
name: 'Grommash', role: 'Bartender', say: 'Pick a mercenary, friend.',
left: '72%', h: 460, z: 6, flip: false, bob: 3.4,
},
{
id: 'elf', sprite: '/scene2d/elf.png', route: 'home',
name: 'Aelis', role: 'Guide', say: 'Welcome to amerc. This way.',
left: '46%', h: 420, z: 5, flip: false, bob: 4.2,
},
{
id: 'dwarf', sprite: '/scene2d/dwarf.png', route: 'booth',
name: 'Durn', role: 'Patron', say: 'My booth runs three agents.',
left: '20%', h: 360, z: 4, flip: true, bob: 3.0,
},
];
// ---------------------------------------------------------------------------
// Top bar
// ---------------------------------------------------------------------------
function PixelTopbar({ route, setRoute, auth }) { function PixelTopbar({ route, setRoute, auth }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const go = (id) => { setRoute(id); setOpen(false); }; const go = (id) => { setRoute(id); setOpen(false); };
@ -128,181 +41,89 @@ function PixelTopbar({ route, setRoute, auth }) {
const isAdmin = auth?.user?.role === 'admin'; const isAdmin = auth?.user?.role === 'admin';
const nav = ( const nav = (
<> <>
{NAV.filter((item) => !(item.id === 'signin' && loggedIn)).map((item) => ( {NAV.map((item) => (
<button key={item.id} className={`px-nav-btn${route === item.id ? ' active' : ''}`} onClick={() => go(item.id)} type="button"> <button key={item.id} className={`px-nav-btn${route === item.id ? ' active' : ''}`} onClick={() => go(item.id)} type="button">{item.label}</button>
{item.label}
</button>
))} ))}
{isAdmin && ( <a className="px-nav-btn" href="https://docs.amerc.ai/">Docs</a>
<button className={`px-nav-btn quartermaster${route === 'admin' ? ' active' : ''}`} onClick={() => go('admin')} type="button"> <a className="px-nav-btn" href="https://pm.amerc.ai/">PM</a>
Quartermaster {isAdmin && <button className={`px-nav-btn quartermaster${route === 'admin' ? ' active' : ''}`} onClick={() => go('admin')} type="button">Quartermaster</button>}
</button> {loggedIn
)} ? <button className="px-nav-btn px-nav-user" onClick={() => auth.logout()} type="button" title="Log out">{auth.user.handle} </button>
{loggedIn && ( : <button className={`px-nav-btn${route === 'signin' ? ' active' : ''}`} onClick={() => go('signin')} type="button">Login</button>}
<button className="px-nav-btn px-nav-user" onClick={() => auth.logout()} type="button" title="Log out">
{auth.user.handle}
</button>
)}
</> </>
); );
return ( return (
<header className="px-topbar"> <header className="px-topbar">
<button className="px-logo" onClick={() => go('home')} type="button" aria-label="amerc home"> <button className="px-logo" onClick={() => go('home')} type="button" aria-label="amerc home">
<span className="px-logo-gem" aria-hidden="true" /> <span className="px-logo-gem" aria-hidden="true" /><strong>amerc</strong><small>agent mercenary tavern</small><em>v{RELEASE}</em>
<strong>amerc</strong>
<small>agent mercenary tavern</small>
<em>v{RELEASE}</em>
</button> </button>
<nav className="px-nav" aria-label="Primary">{nav}</nav> <nav className="px-nav" aria-label="Primary">{nav}</nav>
<button className="px-menu" onClick={() => setOpen((v) => !v)} type="button" aria-label="Toggle menu"> <button className="px-menu" onClick={() => setOpen((v) => !v)} type="button" aria-label="Toggle menu">{open ? <X size={20} /> : <Menu size={20} />}</button>
{open ? <X size={20} /> : <Menu size={20} />}
</button>
{open && <div className="px-mobile">{nav}</div>} {open && <div className="px-mobile">{nav}</div>}
</header> </header>
); );
} }
// --------------------------------------------------------------------------- function StatsStrip() {
// Scene const [s, setS] = useState(null);
// --------------------------------------------------------------------------- useEffect(() => { api('/agents/stats').then(setS).catch(() => {}); }, []);
function TavernFloor({ route, setRoute, auth }) { const items = [
const stageRef = useRef(null); ['classes', 'agent classes'], ['instances', 'instances'], ['online', 'online now'],
const [parallax, setParallax] = useState({ x: 0, y: 0 }); ['sessions', 'live sessions'], ['owners', 'publishers'],
const [active, setActive] = useState(null); // hovered/selected character id ];
const onMove = useCallback((e) => {
const el = stageRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
setParallax({ x: px, y: py });
}, []);
const embers = useMemo(
() => Array.from({ length: 26 }, (_, i) => ({
id: i,
left: `${(i * 37) % 100}%`,
delay: `${(i % 13) * 0.9}s`,
dur: `${7 + (i % 6)}s`,
size: 2 + (i % 3),
})),
[],
);
return ( return (
<div className="px-stage" ref={stageRef} onMouseMove={onMove} onMouseLeave={() => setParallax({ x: 0, y: 0 })}> <div className="td-stats">
{/* parallax tavern backdrop */} {items.map(([k, label]) => (
<div <div key={k} className="td-stat"><b>{s ? s[k] : '—'}</b><span>{label}</span></div>
className="px-bg"
style={{ transform: `translate3d(${parallax.x * -14}px, ${parallax.y * -8}px, 0) scale(1.08)` }}
/>
<div className="px-bg-haze" />
{/* floating embers */}
<div className="px-embers" aria-hidden="true">
{embers.map((e) => (
<span key={e.id} style={{ left: e.left, animationDelay: e.delay, animationDuration: e.dur, width: e.size, height: e.size }} />
))} ))}
</div> </div>
{/* characters on the floor */}
<div
className="px-floor"
style={{ transform: `translate3d(${parallax.x * 10}px, 0, 0)` }}
>
{CHARACTERS.map((c) => (
<button
key={c.id}
type="button"
className={`px-char${active === c.id ? ' active' : ''}${route === c.route ? ' here' : ''}`}
style={{ left: c.left, zIndex: c.z, '--bob': `${c.bob}s` }}
onMouseEnter={() => setActive(c.id)}
onMouseLeave={() => setActive((p) => (p === c.id ? null : p))}
onClick={() => setRoute(c.route)}
aria-label={`${c.name} the ${c.role}`}
>
{(active === c.id || route === c.route) && (
<span className="px-speech">
<strong>{c.name}</strong> · {c.role}
<em>{c.say}</em>
</span>
)}
<img
src={c.sprite}
alt=""
draggable={false}
style={{ height: `min(${c.h}px, 58vh)`, transform: c.flip ? 'scaleX(-1)' : 'none' }}
/>
<span className="px-char-shadow" />
</button>
))}
</div>
{/* warm light + vignette */}
<div className="px-vignette" aria-hidden="true" />
{/* foreground holo board with route content */}
<RoutePanel route={route} setRoute={setRoute} auth={auth} />
</div>
); );
} }
function RoutePanel({ route, setRoute, auth }) { function Home({ setRoute }) {
const data = panelData(route);
const wide = route === 'admin' && auth.user?.role === 'admin';
return ( return (
<aside className={`px-board${wide ? ' px-board-wide' : ''}`} style={{ '--tint': data.tint }}> <div className="td-home">
<div className="px-board-rivets" aria-hidden="true" /> <div className="td-stage">
<header className="px-board-head"> <TopDownTavern onHotspot={setRoute} />
<span className="px-board-tag">{route === 'admin' ? 'BACKDOOR' : route === 'signin' ? 'GATEHOUSE' : 'WANTED BOARD'}</span> <div className="td-hero">
<h2>{data.title}</h2> <h1>Hire agents like mercenaries.</h1>
<p>{data.subtitle}</p> <p>Publish an agent <b>class</b>, run <b>instances</b>, serve users in live <b>sessions</b> as a chatbox or a web terminal.</p>
</header> <div className="td-hero-cta">
<button className="px-action" onClick={() => setRoute('agents')}>Browse Agents</button>
{route === 'signin' && <AuthPanel auth={auth} onDone={() => setRoute('booth')} />} <button className="px-action" onClick={() => setRoute('booth')}>Publish an Agent</button>
{route === 'admin' && <AdminConsole auth={auth} />} </div>
{route !== 'signin' && route !== 'admin' && ( </div>
<> <StatsStrip />
<RouteBody data={data} route={route} setRoute={setRoute} /> </div>
</> </div>
)}
</aside>
); );
} }
function RouteBody({ data, route, setRoute }) { function Page({ children }) { return <div className="td-page">{children}</div>; }
return (
<> function Scene({ route, setRoute, auth }) {
<ul className="px-board-rows"> if (route === 'home') return <Home setRoute={setRoute} />;
{data.rows.map((r, i) => ( if (route === 'agents') return <Page><AgentBrowse auth={auth} setRoute={setRoute} /></Page>;
<li key={i}><span className="px-bullet" />{r}</li> if (route === 'booth') return <Page><BoothDashboard auth={auth} setRoute={setRoute} /></Page>;
))} if (route === 'signin') return <Page><div className="td-signin"><div className="px-board" style={{ position: 'static', transform: 'none', width: 'min(380px,92vw)', margin: '0 auto' }}>
</ul> <header className="px-board-head"><span className="px-board-tag">GATEHOUSE</span><h2>Open Booth</h2><p>One amerc account across tavern, docs, PM and git.</p></header>
<div className="px-board-stats"> <AuthPanel auth={auth} onDone={() => setRoute('booth')} />
{data.stats.map((s, i) => ( </div></div></Page>;
<span key={i} className="px-stat">{s}</span> if (route === 'admin') return <Page><div className="px-board" style={{ position: 'static', transform: 'none', width: 'min(720px,94vw)', margin: '0 auto' }}>
))} <header className="px-board-head"><span className="px-board-tag">BACKDOOR</span><h2>Quartermaster</h2><p>Tenant, key, session and risk controls.</p></header>
</div> <AdminConsole auth={auth} />
<div className="px-board-actions"> </div></Page>;
{data.actions.map((a, i) => ( return <Home setRoute={setRoute} />;
<button key={i} type="button" className="px-action" onClick={() => setRoute(route === 'home' ? 'agents' : route)}>
{a}
</button>
))}
</div>
</>
);
} }
export default function Scene2DApp() { export default function Scene2DApp() {
const [route, setRoute] = useHashRoute(); const [route, setRoute] = useHashRoute();
const auth = useAuth(); const auth = useAuth();
return ( return (
<main className="px-app"> <main className="px-app td-app">
<h1 className="sr-only">amerc agent mercenary tavern</h1> <h1 className="sr-only">amerc agent mercenary tavern</h1>
<PixelTopbar route={route} setRoute={setRoute} auth={auth} /> <PixelTopbar route={route} setRoute={setRoute} auth={auth} />
<TavernFloor route={route} setRoute={setRoute} auth={auth} /> <Scene route={route} setRoute={setRoute} auth={auth} />
</main> </main>
); );
} }

146
src/TopDownTavern.jsx Normal file
View File

@ -0,0 +1,146 @@
import React, { useEffect, useRef, useState } from 'react';
// 3/4 top-down RPG tavern, drawn procedurally on a canvas at a fixed virtual size.
// Pixel-chunky: rendered at TILE px then scaled up with image-rendering:pixelated.
const COLS = 20, ROWS = 13, TILE = 16; // virtual room = 320x208, scaled up
const W = COLS * TILE, H = ROWS * TILE;
// tile map legend: . floor # wall = bar T table o stool b barrel r rug f fireplace s shelf c crate p plant w window
const MAP = [
'####################',
'#wwww##ffff##wwww##s#',
'#..................s#',
'#..bb....rr....cc...#',
'#..bb..rrrrrr..cc...#',
'#......rrrrrr.......#',
'#..T...rrrrrr...T...#',
'#.ooo..........ooo..#',
'#..................##',
'#===================#',
'#...=...=...=...=...##',
'#..................##',
'####################',
];
const PAL = {
floorA: '#6b4a2b', floorB: '#5e4126', grain: '#4f361f',
wall: '#3a2a3f', wallTop: '#52405a', mortar: '#241a28',
rug: '#7a1f2b', rugEdge: '#d8a843', rugMid: '#9c2a38',
bar: '#7a4a24', barTop: '#a06a36', barEdge: '#3c220f',
wood: '#8a5a2e', woodHi: '#a87a44', woodLo: '#4a2e16',
barrel: '#6b4524', barrelBand: '#caa54a', barrelTop: '#a8732f',
stone: '#4a4452', stoneHi: '#6a6276',
fire: '#ff9f38', fireHot: '#ffd66b', glass: '#2aa9d8',
};
function px(ctx, x, y, w, h, c) { ctx.fillStyle = c; ctx.fillRect(x, y, w, h); }
function drawFloor(ctx, x, y) {
const a = ((x / TILE) + (y / TILE)) % 2 === 0;
px(ctx, x, y, TILE, TILE, a ? PAL.floorA : PAL.floorB);
ctx.fillStyle = PAL.grain;
ctx.fillRect(x, y + 5, TILE, 1); ctx.fillRect(x, y + 11, TILE, 1);
ctx.fillRect(x + (a ? 4 : 11), y, 1, TILE);
}
function drawWall(ctx, x, y) {
px(ctx, x, y, TILE, TILE, PAL.wall);
px(ctx, x, y, TILE, 4, PAL.wallTop); // 3/4 top cap
ctx.fillStyle = PAL.mortar;
ctx.fillRect(x, y + 9, TILE, 1);
ctx.fillRect(x + ((y / TILE) % 2 ? 8 : 0), y + 4, 1, 5);
ctx.fillRect(x + ((y / TILE) % 2 ? 0 : 8), y + 9, 1, 7);
}
function drawRug(ctx, x, y, edge, midOnly) {
px(ctx, x, y, TILE, TILE, PAL.rug);
if (midOnly) { px(ctx, x + 3, y + 3, TILE - 6, TILE - 6, PAL.rugMid); }
ctx.fillStyle = PAL.rugEdge;
if (edge.t) ctx.fillRect(x, y, TILE, 2);
if (edge.b) ctx.fillRect(x, y + TILE - 2, TILE, 2);
if (edge.l) ctx.fillRect(x, y, 2, TILE);
if (edge.r) ctx.fillRect(x + TILE - 2, y, 2, TILE);
}
function drawTile(ctx, ch, x, y, neigh) {
switch (ch) {
case '#': drawWall(ctx, x, y); break;
case 'w': drawWall(ctx, x, y); px(ctx, x + 3, y + 5, TILE - 6, TILE - 8, '#0a1830'); px(ctx, x + 4, y + 6, TILE - 8, TILE - 10, PAL.glass); px(ctx, x + 7, y + 6, 1, TILE - 10, '#0a1830'); break;
case 's': drawWall(ctx, x, y); px(ctx, x + 2, y + 4, TILE - 4, 2, PAL.wood); px(ctx, x + 2, y + 8, TILE - 4, 2, PAL.wood); px(ctx, x + 2, y + 12, TILE - 4, 2, PAL.wood); px(ctx, x + 4, y + 1, 2, 3, PAL.woodHi); px(ctx, x + 9, y + 5, 2, 3, PAL.fire); break;
case 'f': drawWall(ctx, x, y); px(ctx, x + 2, y + 6, TILE - 4, TILE - 6, '#1a1014'); break;
default: drawFloor(ctx, x, y);
if (ch === 'r') {
const e = { t: neigh.t !== 'r', b: neigh.b !== 'r', l: neigh.l !== 'r', r: neigh.r !== 'r' };
drawRug(ctx, x, y, e, true);
}
if (ch === '=') { // bar counter
px(ctx, x, y + 3, TILE, TILE - 3, PAL.bar); px(ctx, x, y, TILE, 4, PAL.barTop); px(ctx, x, y + 3, TILE, 1, PAL.barEdge); px(ctx, x, y + TILE - 1, TILE, 1, PAL.barEdge);
}
if (ch === 'T') { px(ctx, x + 1, y + 2, TILE - 2, TILE - 4, PAL.woodLo); px(ctx, x + 1, y + 1, TILE - 2, 4, PAL.wood); px(ctx, x + 3, y + 2, TILE - 6, 1, PAL.woodHi); }
if (ch === 'o') { px(ctx, x + 5, y + 6, 6, 6, PAL.woodLo); px(ctx, x + 5, y + 5, 6, 2, PAL.wood); }
if (ch === 'b') { px(ctx, x + 3, y + 2, TILE - 6, TILE - 3, PAL.barrel); px(ctx, x + 3, y + 1, TILE - 6, 3, PAL.barrelTop); px(ctx, x + 3, y + 6, TILE - 6, 1, PAL.barrelBand); px(ctx, x + 3, y + 11, TILE - 6, 1, PAL.barrelBand); }
if (ch === 'c') { px(ctx, x + 2, y + 3, TILE - 4, TILE - 4, PAL.wood); px(ctx, x + 2, y + 2, TILE - 4, 2, PAL.woodHi); ctx.fillStyle = PAL.woodLo; ctx.fillRect(x + 2, y + 8, TILE - 4, 1); ctx.fillRect(x + 8, y + 3, 1, TILE - 5); }
}
}
export default function TopDownTavern({ stage = {}, onHotspot }) {
const canvasRef = useRef(null);
const [t, setT] = useState(0);
useEffect(() => {
const ctx = canvasRef.current.getContext('2d');
ctx.imageSmoothingEnabled = false;
let raf, frame = 0;
const at = (c, r) => (MAP[r] && MAP[r][c]) || '.';
const render = () => {
frame++;
// base map
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const ch = at(c, r);
drawTile(ctx, ch, c * TILE, r * TILE, { t: at(c, r - 1), b: at(c, r + 1), l: at(c - 1, r), r: at(c + 1, r) });
}
// fireplace fire (animated) under the 'f' wall tiles row1 cols6-9
const flick = 0.6 + 0.4 * Math.sin(frame * 0.2);
for (let c = 6; c <= 9; c++) {
const x = c * TILE, y = 1 * TILE;
px(ctx, x + 3, y + 8 - flick * 3, TILE - 6, 6 + flick * 3, PAL.fire);
px(ctx, x + 5, y + 10 - flick * 2, TILE - 10, 4, PAL.fireHot);
}
// wall lantern glow (animated) near windows
ctx.globalAlpha = 0.10 + 0.05 * Math.sin(frame * 0.15);
[[3, 2], [16, 2], [10, 1]].forEach(([c, r]) => { ctx.fillStyle = PAL.fire; ctx.beginPath(); ctx.arc(c * TILE + 8, r * TILE + 10, 14, 0, 7); ctx.fill(); });
ctx.globalAlpha = 1;
raf = requestAnimationFrame(render);
if (frame % 30 === 0) setT(frame);
};
render();
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className="td-room">
<canvas ref={canvasRef} width={W} height={H} className="td-canvas" />
<div className="td-overlay">
{/* characters (sprites as NPCs over the floor) */}
<Npc sprite="/scene2d/orc.png" x={50} y={56} h={20} label="Grommash · Bartender" say="Pick a mercenary, friend." onClick={() => onHotspot?.('agents')} />
<Npc sprite="/scene2d/elf.png" x={73} y={62} h={19} flip label="Aelis · Guide" say="Browse the roster, or open your booth." onClick={() => onHotspot?.('agents')} />
<Npc sprite="/scene2d/dwarf.png" x={24} y={66} h={17} label="Durn · Patron" say="My booth runs three agents." onClick={() => onHotspot?.('booth')} />
{/* hotspots */}
<button className="td-hot td-hot-board" style={{ left: '72%', top: '20%' }} onClick={() => onHotspot?.('agents')}>
<span className="td-hot-tag">NOTICE BOARD</span>
<strong>Browse Agents </strong>
</button>
<button className="td-hot td-hot-door" style={{ left: '50%', top: '74%' }} onClick={() => onHotspot?.('booth')}>Enter My Booth </button>
</div>
</div>
);
}
function Npc({ sprite, x, y, h, flip, label, say, onClick }) {
const [hover, setHover] = useState(false);
return (
<button className="td-npc" style={{ left: `${x}%`, top: `${y}%`, height: `${h}%` }}
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} onClick={onClick} aria-label={label}>
{hover && <span className="td-say"><strong>{label}</strong><em>{say}</em></span>}
<img src={sprite} alt="" draggable={false} style={{ transform: flip ? 'scaleX(-1)' : 'none' }} />
<span className="td-npc-shadow" />
</button>
);
}

View File

@ -791,12 +791,23 @@ button {
.cs-user button { padding: 6px 12px; border-radius: 7px; background: #1b2536; color: #dce6f5; cursor: pointer; font-size: 12px; } .cs-user button { padding: 6px 12px; border-radius: 7px; background: #1b2536; color: #dce6f5; cursor: pointer; font-size: 12px; }
.cs-main { flex: 1; min-height: 0; overflow: hidden; display: flex; } .cs-main { flex: 1; min-height: 0; overflow: hidden; display: flex; }
.cs-login-wrap { margin: auto; } /* RPG building-block login frame — matches the amerc tavern look across all sites */
.cs-login { display: flex; flex-direction: column; gap: 12px; width: 320px; padding: 28px; background: #0e1422; border: 1px solid #1f2a3d; border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); } .cs-login-wrap { margin: auto; padding: 20px; }
.cs-login .cs-brand { justify-content: center; margin-bottom: 6px; } .cs-login { display: flex; flex-direction: column; gap: 12px; width: 330px; padding: 26px 24px; color: #fff0d7;
background: linear-gradient(180deg, #2a1608, #160a04); border: 3px solid #000; border-radius: 4px;
box-shadow: inset 0 0 0 2px #6b3a1c, inset 0 0 26px rgba(0,0,0,0.7), 6px 6px 0 #000, 0 24px 60px rgba(0,0,0,0.6);
font-family: "Press Start 2P", ui-monospace, monospace; }
.cs-login .cs-brand { justify-content: center; margin-bottom: 8px; color: #f7bc5d; }
.cs-login .cs-gem { background: #27d8ff; }
.cs-tabs { display: flex; gap: 6px; } .cs-tabs { display: flex; gap: 6px; }
.cs-tabs button { flex: 1; padding: 8px; border-radius: 8px; background: #18213200; background: #182132; color: #aab8d0; cursor: pointer; font-size: 13px; } .cs-tabs button { flex: 1; padding: 9px; font-family: inherit; font-size: 9px; letter-spacing: 1px; color: #fff0d7;
.cs-tabs button.on { background: var(--accent); color: #061018; font-weight: 600; } background: #3a2110; border: 2px solid #000; box-shadow: inset -2px -2px 0 #1c0f06, inset 2px 2px 0 #6b3a1c; cursor: pointer; }
.cs-tabs button.on { background: var(--accent); color: #061018; font-weight: 600; box-shadow: inset -2px -2px 0 rgba(0,0,0,0.3), inset 2px 2px 0 rgba(255,255,255,0.3); }
.cs-login input { font-family: inherit !important; font-size: 10px; padding: 11px; color: #fff; background: #0c0814; border: 2px solid #000; border-radius: 0; box-shadow: inset 1px 1px 0 #251a3a; }
.cs-login .cs-btn, .cs-login .cs-primary { font-family: inherit; font-size: 10px; border-radius: 0; border: 2px solid #000; letter-spacing: 1px;
box-shadow: inset -2px -2px 0 rgba(0,0,0,0.35), inset 2px 2px 0 rgba(255,255,255,0.18), 2px 2px 0 #000; padding: 11px; }
.cs-login .cs-hint { font-size: 8px; line-height: 1.7; text-align: center; }
.cs-login .cs-err { font-size: 9px; }
.cs-login input, .cs-field input, .nd-folder-input, .nd-bar select, .wb-name, .docs-side-top input, .docs-title-input, .docs-folder-input, .pm-nav + * input { font-family: inherit; } .cs-login input, .cs-field input, .nd-folder-input, .nd-bar select, .wb-name, .docs-side-top input, .docs-title-input, .docs-folder-input, .pm-nav + * input { font-family: inherit; }
.cs-login input { padding: 11px; border-radius: 8px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; font-size: 14px; } .cs-login input { padding: 11px; border-radius: 8px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; font-size: 14px; }
.cs-login input:focus, textarea:focus, .nd-text:focus { outline: 2px solid var(--accent); border-color: transparent; } .cs-login input:focus, textarea:focus, .nd-text:focus { outline: 2px solid var(--accent); border-color: transparent; }
@ -905,3 +916,96 @@ button {
.nd-grid { flex-direction: column; } .nd-list { max-width: none; } .nd-grid { flex-direction: column; } .nd-list { max-width: none; }
.cs-appnav { display: none; } .cs-appnav { display: none; }
} }
/* ===================================================================== */
/* 0.21 — top-down RPG tavern + agent platform */
/* ===================================================================== */
.td-app { background: #0a0710; }
.td-home { position: relative; flex: 1; display: flex; align-items: center; justify-content: center; padding: 18px; overflow: hidden; }
.td-stage { position: relative; aspect-ratio: 320 / 208; width: min(96vw, calc((100vh - 150px) * 1.538)); box-shadow: 0 16px 50px rgba(0,0,0,0.6), inset 0 0 0 4px #1a0f08; }
.td-room { position: absolute; inset: 0; }
.td-canvas { width: 100%; height: 100%; display: block; image-rendering: pixelated; }
.td-overlay { position: absolute; inset: 0; }
.td-npc { position: absolute; transform: translateX(-50%); bottom: auto; background: none; cursor: pointer; padding: 0; filter: drop-shadow(2px 3px 0 rgba(0,0,0,0.5)); }
.td-npc img { height: 100%; image-rendering: pixelated; display: block; animation: td-bob 3.6s ease-in-out infinite; }
.td-npc:hover { filter: drop-shadow(0 0 8px rgba(255,200,120,0.7)) drop-shadow(2px 3px 0 rgba(0,0,0,0.5)); z-index: 5; }
@keyframes td-bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-4%)} }
.td-npc-shadow { position: absolute; left: 50%; bottom: -4px; width: 60%; height: 8%; transform: translateX(-50%); background: radial-gradient(ellipse, rgba(0,0,0,0.5), transparent 70%); }
.td-say { position: absolute; bottom: 102%; left: 50%; transform: translateX(-50%); width: max-content; max-width: 180px; padding: 6px 8px; font-size: 8px; line-height: 1.6; color: #1c1206; background: var(--cream); border: 2px solid #000; box-shadow: 2px 2px 0 #000; text-align: center; z-index: 6; }
.td-say strong { color: #7a3d0c; } .td-say em { display: block; font-style: normal; color: #3a2a14; margin-top: 2px; }
.td-hot { position: absolute; transform: translate(-50%, -50%); font-family: "Press Start 2P", monospace; cursor: pointer; z-index: 4; }
.td-hot-board { display: flex; flex-direction: column; gap: 4px; align-items: center; padding: 8px 14px; color: #cdeaff; background: rgba(8,20,36,0.82); border: 2px solid #27d8ff; box-shadow: 0 0 18px rgba(39,216,255,0.4), 3px 3px 0 #000; font-size: 11px; }
.td-hot-board .td-hot-tag { font-size: 6px; letter-spacing: 2px; color: #27d8ff; }
.td-hot-board:hover { background: rgba(12,30,52,0.92); }
.td-hot-door { padding: 8px 14px; font-size: 10px; color: #36f0b0; background: rgba(8,24,18,0.82); border: 2px solid #36f0b0; box-shadow: 0 0 16px rgba(54,240,176,0.35), 3px 3px 0 #000; }
.td-hot-door:hover { background: rgba(12,34,26,0.92); }
.td-hero { position: absolute; left: 3%; top: 8%; width: min(42%, 360px); z-index: 4; font-family: "Press Start 2P", monospace; }
.td-hero h1 { font-size: clamp(13px, 2.1vw, 22px); line-height: 1.5; color: #fff; text-shadow: 2px 2px 0 #000, 0 0 18px rgba(0,0,0,0.8); margin: 0 0 10px; }
.td-hero p { font-family: Inter, sans-serif; font-size: clamp(10px, 1.3vw, 13px); line-height: 1.7; color: #e7d4b6; text-shadow: 1px 1px 2px #000; margin: 0 0 12px; }
.td-hero-cta { display: flex; gap: 8px; flex-wrap: wrap; }
.td-stats { position: absolute; left: 50%; bottom: 3%; transform: translateX(-50%); display: flex; gap: 6px; z-index: 4; }
.td-stat { display: flex; flex-direction: column; align-items: center; padding: 6px 10px; background: rgba(8,12,22,0.8); border: 2px solid #000; box-shadow: 2px 2px 0 #000; font-family: "Press Start 2P", monospace; }
.td-stat b { font-size: 14px; color: #27d8ff; } .td-stat span { font-size: 6px; color: #9fb0d0; margin-top: 3px; letter-spacing: 1px; }
.td-page { flex: 1; overflow: auto; background: #0a0710 radial-gradient(circle at 50% -10%, #1a0f22, #0a0710 60%); }
.td-signin { padding: 40px 16px; } .td-app .td-page .px-board { color: var(--cream); }
/* agent platform */
.ap { max-width: 1080px; margin: 0 auto; padding: 24px 20px 60px; color: #e7d4b6; font-family: Inter, sans-serif; }
.ap-head { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
.ap-head h2 { font-family: "Press Start 2P", monospace; font-size: 18px; color: #fff; margin: 0; }
.ap-by { color: #9a86b8; font-size: 13px; }
.ap-search { margin-left: auto; padding: 9px 12px; border-radius: 8px; background: #140d1e; border: 1px solid #2e2440; color: #eaf2ff; font-family: inherit; }
.ap-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 18px; }
.ap-tag { padding: 6px 10px; border-radius: 16px; background: #181226; border: 1px solid #2e2440; color: #c9b8e6; cursor: pointer; font-size: 12px; }
.ap-tag.on { background: #27d8ff; color: #061018; border-color: transparent; font-weight: 600; }
.ap-tag em { opacity: 0.6; font-style: normal; }
.ap-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
.ap-card { text-align: left; padding: 16px; border-radius: 12px; background: #130d20; border: 1px solid #271c3a; cursor: pointer; display: flex; flex-direction: column; gap: 8px; color: inherit; }
.ap-card:hover { border-color: #27d8ff; transform: translateY(-2px); transition: 0.12s; }
.ap-card-top { display: flex; align-items: center; gap: 8px; } .ap-card-top strong { font-size: 15px; color: #fff; }
.ap-kind { font-size: 10px; padding: 2px 7px; border-radius: 5px; background: #271c3a; color: #c9b8e6; }
.ap-desc { font-size: 13px; color: #b9a6cf; margin: 0; line-height: 1.6; }
.ap-card-tags { display: flex; gap: 5px; flex-wrap: wrap; }
.ap-card-tags span { font-size: 10px; padding: 2px 7px; border-radius: 10px; background: #1d1530; color: #9d8fc0; }
.ap-card-foot { display: flex; justify-content: space-between; font-size: 11px; color: #8a7aa8; margin-top: auto; }
.ap-online { display: flex; align-items: center; gap: 5px; } .ap-online i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.ap-hint { color: #8a7aa8; font-size: 12px; line-height: 1.6; }
.ap-h4 { font-family: "Press Start 2P", monospace; font-size: 10px; color: #27d8ff; margin: 16px 0 8px; }
.ap-modal { position: fixed; inset: 0; background: rgba(4,6,14,0.75); display: flex; align-items: center; justify-content: center; z-index: 200; padding: 16px; }
.ap-modal-box { width: min(640px, 96vw); max-height: 88vh; overflow: auto; background: #120c1e; border: 2px solid #2e2440; border-radius: 14px; padding: 18px; }
.ap-modal-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; margin-bottom: 8px; }
.ap-modal-head h3 { margin: 0; color: #fff; } .ap-modal-head button { background: #271c3a; color: #ddd; border-radius: 6px; padding: 4px 10px; cursor: pointer; }
.ap-sub { display: flex; align-items: center; gap: 10px; margin: 12px 0; flex-wrap: wrap; }
.ap-token { font-family: ui-monospace, monospace; font-size: 12px; padding: 6px 9px; background: #0a0f1a; border: 1px solid #2e2440; border-radius: 7px; color: #36f0b0; cursor: pointer; word-break: break-all; }
.ap-inst-list { display: flex; flex-direction: column; gap: 8px; }
.ap-inst { display: flex; align-items: center; gap: 10px; padding: 9px 12px; background: #0e0a18; border: 1px solid #241a36; border-radius: 9px; font-size: 13px; }
.ap-inst-status { width: 10px; height: 10px; border-radius: 50%; flex: none; }
.ap-inst-name { font-weight: 600; color: #eee; } .ap-inst-meta { color: #8a7aa8; font-size: 11px; margin-left: auto; margin-right: 8px; }
.ap-use { margin-top: 14px; border: 1px solid #2e2440; border-radius: 10px; overflow: hidden; }
.ap-use-head { display: flex; justify-content: space-between; align-items: center; background: #0e0a18; padding: 6px 8px; }
.ap-use-tabs button { padding: 6px 10px; border-radius: 6px; background: #1d1530; color: #c9b8e6; cursor: pointer; font-size: 12px; margin-right: 4px; }
.ap-use-tabs button.on { background: #27d8ff; color: #061018; }
.ap-use-x { background: none; color: #8a7aa8; cursor: pointer; font-size: 12px; }
.ap-use-start { padding: 14px; display: flex; gap: 8px; flex-wrap: wrap; }
.ap-use-start input { padding: 8px; border-radius: 7px; background: #0a0f1a; border: 1px solid #2e2440; color: #eaf2ff; flex: 1; }
.ap-iframe { width: 100%; height: 440px; border: 0; background: #fff; display: block; }
.ap-embed { padding: 10px; background: #0a0f1a; font-size: 12px; } .ap-embed code { color: #36f0b0; font-family: ui-monospace, monospace; word-break: break-all; }
.ap-gate { text-align: center; padding-top: 40px; }
.ap-booth-grid { display: grid; grid-template-columns: 320px 1fr; gap: 20px; align-items: start; }
.ap-publish { display: flex; flex-direction: column; gap: 8px; padding: 16px; background: #130d20; border: 1px solid #271c3a; border-radius: 12px; }
.ap-publish input, .ap-publish textarea, .ap-publish select { padding: 9px; border-radius: 7px; background: #0a0f1a; border: 1px solid #2e2440; color: #eaf2ff; font-family: inherit; font-size: 13px; }
.ap-publish textarea { min-height: 60px; resize: vertical; } .ap-row { display: flex; gap: 8px; } .ap-row > * { flex: 1; }
.ap-mine-class, .ap-mine-inst { padding: 10px 12px; background: #0e0a18; border: 1px solid #241a36; border-radius: 9px; margin-bottom: 8px; }
.ap-mine-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 13px; } .ap-mine-row strong { color: #fff; } .ap-mine-row span { color: #8a7aa8; font-size: 12px; }
.ap-mine-inst { display: flex; align-items: center; gap: 10px; font-size: 13px; } .ap-mine-inst em { font-style: normal; color: #c9b8e6; }
.ap-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #9a86b8; margin: 8px 0; }
@media (max-width: 760px) {
.td-hero { position: static; width: auto; padding: 12px; }
.td-stage { width: 96vw; } .td-stats { position: static; transform: none; flex-wrap: wrap; justify-content: center; margin-top: 10px; }
.td-home { flex-direction: column; } .ap-booth-grid { grid-template-columns: 1fr; }
}