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:
parent
4a483638a7
commit
f85301c33d
@ -7,7 +7,7 @@
|
||||
name="description"
|
||||
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="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "amerc-site",
|
||||
"version": "0.20.0-suite",
|
||||
"version": "0.21.1-pool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -43,8 +43,40 @@ db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS boards (
|
||||
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);
|
||||
|
||||
-- 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 -----------------------------------------------------------------
|
||||
function hashPassword(pw) {
|
||||
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 })) });
|
||||
}
|
||||
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/password' && method === 'POST') {
|
||||
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 }); }
|
||||
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' });
|
||||
} catch (err) {
|
||||
return send(res, 500, { ok: false, error: 'server error', detail: String(err && err.message || err) });
|
||||
|
||||
116
server/platform-tests.mjs
Normal file
116
server/platform-tests.mjs
Normal 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
233
src/AgentPlatform.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
305
src/Scene2D.jsx
305
src/Scene2D.jsx
@ -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 { 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 = [
|
||||
{ id: 'home', label: 'Solution' },
|
||||
{ id: 'agents', label: 'Staff' },
|
||||
{ id: 'contracts', label: 'Pricing' },
|
||||
{ id: 'signin', label: 'Login' },
|
||||
{ id: 'home', label: 'Tavern' },
|
||||
{ id: 'agents', label: 'Browse Agents' },
|
||||
{ id: 'booth', label: 'My Booth' },
|
||||
];
|
||||
|
||||
const VALID_ROUTES = ['home', 'agents', 'booth', 'contracts', 'admin', 'signin'];
|
||||
const VALID_ROUTES = ['home', 'agents', 'booth', 'admin', 'signin'];
|
||||
|
||||
function useHashRoute() {
|
||||
const normalize = () => {
|
||||
@ -27,100 +24,16 @@ function useHashRoute() {
|
||||
const onHash = () => setRouteState(normalize());
|
||||
window.addEventListener('hashchange', onHash);
|
||||
window.addEventListener('popstate', onHash);
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', onHash);
|
||||
window.removeEventListener('popstate', onHash);
|
||||
};
|
||||
return () => { window.removeEventListener('hashchange', onHash); window.removeEventListener('popstate', onHash); };
|
||||
}, []);
|
||||
const setRoute = useCallback((next) => {
|
||||
const nextHash = `/${next}`;
|
||||
if (window.location.hash !== `#${nextHash}`) {
|
||||
window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#${nextHash}`);
|
||||
}
|
||||
if (window.location.hash !== `#${nextHash}`) window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#${nextHash}`);
|
||||
setRouteState(next);
|
||||
}, []);
|
||||
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 }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const go = (id) => { setRoute(id); setOpen(false); };
|
||||
@ -128,181 +41,89 @@ function PixelTopbar({ route, setRoute, auth }) {
|
||||
const isAdmin = auth?.user?.role === 'admin';
|
||||
const nav = (
|
||||
<>
|
||||
{NAV.filter((item) => !(item.id === 'signin' && loggedIn)).map((item) => (
|
||||
<button key={item.id} className={`px-nav-btn${route === item.id ? ' active' : ''}`} onClick={() => go(item.id)} type="button">
|
||||
{item.label}
|
||||
</button>
|
||||
{NAV.map((item) => (
|
||||
<button key={item.id} className={`px-nav-btn${route === item.id ? ' active' : ''}`} onClick={() => go(item.id)} type="button">{item.label}</button>
|
||||
))}
|
||||
{isAdmin && (
|
||||
<button className={`px-nav-btn quartermaster${route === 'admin' ? ' active' : ''}`} onClick={() => go('admin')} type="button">
|
||||
Quartermaster
|
||||
</button>
|
||||
)}
|
||||
{loggedIn && (
|
||||
<button className="px-nav-btn px-nav-user" onClick={() => auth.logout()} type="button" title="Log out">
|
||||
{auth.user.handle} ⏻
|
||||
</button>
|
||||
)}
|
||||
<a className="px-nav-btn" href="https://docs.amerc.ai/">Docs</a>
|
||||
<a className="px-nav-btn" href="https://pm.amerc.ai/">PM</a>
|
||||
{isAdmin && <button className={`px-nav-btn quartermaster${route === 'admin' ? ' active' : ''}`} onClick={() => go('admin')} type="button">Quartermaster</button>}
|
||||
{loggedIn
|
||||
? <button className="px-nav-btn px-nav-user" onClick={() => auth.logout()} type="button" title="Log out">{auth.user.handle} ⏻</button>
|
||||
: <button className={`px-nav-btn${route === 'signin' ? ' active' : ''}`} onClick={() => go('signin')} type="button">Login</button>}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<header className="px-topbar">
|
||||
<button className="px-logo" onClick={() => go('home')} type="button" aria-label="amerc home">
|
||||
<span className="px-logo-gem" aria-hidden="true" />
|
||||
<strong>amerc</strong>
|
||||
<small>agent mercenary tavern</small>
|
||||
<em>v{RELEASE}</em>
|
||||
<span className="px-logo-gem" aria-hidden="true" /><strong>amerc</strong><small>agent mercenary tavern</small><em>v{RELEASE}</em>
|
||||
</button>
|
||||
<nav className="px-nav" aria-label="Primary">{nav}</nav>
|
||||
<button className="px-menu" onClick={() => setOpen((v) => !v)} type="button" aria-label="Toggle menu">
|
||||
{open ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
<button className="px-menu" onClick={() => setOpen((v) => !v)} type="button" aria-label="Toggle menu">{open ? <X size={20} /> : <Menu size={20} />}</button>
|
||||
{open && <div className="px-mobile">{nav}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene
|
||||
// ---------------------------------------------------------------------------
|
||||
function TavernFloor({ route, setRoute, auth }) {
|
||||
const stageRef = useRef(null);
|
||||
const [parallax, setParallax] = useState({ x: 0, y: 0 });
|
||||
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),
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
function StatsStrip() {
|
||||
const [s, setS] = useState(null);
|
||||
useEffect(() => { api('/agents/stats').then(setS).catch(() => {}); }, []);
|
||||
const items = [
|
||||
['classes', 'agent classes'], ['instances', 'instances'], ['online', 'online now'],
|
||||
['sessions', 'live sessions'], ['owners', 'publishers'],
|
||||
];
|
||||
return (
|
||||
<div className="px-stage" ref={stageRef} onMouseMove={onMove} onMouseLeave={() => setParallax({ x: 0, y: 0 })}>
|
||||
{/* parallax tavern backdrop */}
|
||||
<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>
|
||||
|
||||
{/* 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 className="td-stats">
|
||||
{items.map(([k, label]) => (
|
||||
<div key={k} className="td-stat"><b>{s ? s[k] : '—'}</b><span>{label}</span></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutePanel({ route, setRoute, auth }) {
|
||||
const data = panelData(route);
|
||||
const wide = route === 'admin' && auth.user?.role === 'admin';
|
||||
function Home({ setRoute }) {
|
||||
return (
|
||||
<aside className={`px-board${wide ? ' px-board-wide' : ''}`} style={{ '--tint': data.tint }}>
|
||||
<div className="px-board-rivets" aria-hidden="true" />
|
||||
<header className="px-board-head">
|
||||
<span className="px-board-tag">{route === 'admin' ? 'BACKDOOR' : route === 'signin' ? 'GATEHOUSE' : 'WANTED BOARD'}</span>
|
||||
<h2>{data.title}</h2>
|
||||
<p>{data.subtitle}</p>
|
||||
</header>
|
||||
|
||||
{route === 'signin' && <AuthPanel auth={auth} onDone={() => setRoute('booth')} />}
|
||||
{route === 'admin' && <AdminConsole auth={auth} />}
|
||||
{route !== 'signin' && route !== 'admin' && (
|
||||
<>
|
||||
<RouteBody data={data} route={route} setRoute={setRoute} />
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
<div className="td-home">
|
||||
<div className="td-stage">
|
||||
<TopDownTavern onHotspot={setRoute} />
|
||||
<div className="td-hero">
|
||||
<h1>Hire agents like mercenaries.</h1>
|
||||
<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>
|
||||
<div className="td-hero-cta">
|
||||
<button className="px-action" onClick={() => setRoute('agents')}>Browse Agents</button>
|
||||
<button className="px-action" onClick={() => setRoute('booth')}>Publish an Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
<StatsStrip />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteBody({ data, route, setRoute }) {
|
||||
return (
|
||||
<>
|
||||
<ul className="px-board-rows">
|
||||
{data.rows.map((r, i) => (
|
||||
<li key={i}><span className="px-bullet" />{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="px-board-stats">
|
||||
{data.stats.map((s, i) => (
|
||||
<span key={i} className="px-stat">{s}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-board-actions">
|
||||
{data.actions.map((a, i) => (
|
||||
<button key={i} type="button" className="px-action" onClick={() => setRoute(route === 'home' ? 'agents' : route)}>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
function Page({ children }) { return <div className="td-page">{children}</div>; }
|
||||
|
||||
function Scene({ route, setRoute, auth }) {
|
||||
if (route === 'home') return <Home setRoute={setRoute} />;
|
||||
if (route === 'agents') return <Page><AgentBrowse auth={auth} setRoute={setRoute} /></Page>;
|
||||
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' }}>
|
||||
<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>
|
||||
<AuthPanel auth={auth} onDone={() => setRoute('booth')} />
|
||||
</div></div></Page>;
|
||||
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>
|
||||
<AdminConsole auth={auth} />
|
||||
</div></Page>;
|
||||
return <Home setRoute={setRoute} />;
|
||||
}
|
||||
|
||||
export default function Scene2DApp() {
|
||||
const [route, setRoute] = useHashRoute();
|
||||
const auth = useAuth();
|
||||
return (
|
||||
<main className="px-app">
|
||||
<main className="px-app td-app">
|
||||
<h1 className="sr-only">amerc agent mercenary tavern</h1>
|
||||
<PixelTopbar route={route} setRoute={setRoute} auth={auth} />
|
||||
<TavernFloor route={route} setRoute={setRoute} auth={auth} />
|
||||
<Scene route={route} setRoute={setRoute} auth={auth} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
146
src/TopDownTavern.jsx
Normal file
146
src/TopDownTavern.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/styles.css
114
src/styles.css
@ -791,12 +791,23 @@ button {
|
||||
.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-login-wrap { margin: auto; }
|
||||
.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 .cs-brand { justify-content: center; margin-bottom: 6px; }
|
||||
/* RPG building-block login frame — matches the amerc tavern look across all sites */
|
||||
.cs-login-wrap { margin: auto; padding: 20px; }
|
||||
.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 button { flex: 1; padding: 8px; border-radius: 8px; background: #18213200; background: #182132; color: #aab8d0; cursor: pointer; font-size: 13px; }
|
||||
.cs-tabs button.on { background: var(--accent); color: #061018; font-weight: 600; }
|
||||
.cs-tabs button { flex: 1; padding: 9px; font-family: inherit; font-size: 9px; letter-spacing: 1px; color: #fff0d7;
|
||||
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 { 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; }
|
||||
@ -905,3 +916,96 @@ button {
|
||||
.nd-grid { flex-direction: column; } .nd-list { max-width: 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; }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user