amerc/src/Scene2D.jsx
artheru b055663372 amerc suite: 2D pixel tavern + zero-dep auth/admin/docs/pm backend
- Scene2D pixel tavern (replaces three.js 3D scene)
- amerc-api: node:http+node:sqlite+node:crypto, auth/admin/docs/files/boards
- docs.amerc.ai + pm.amerc.ai (whiteboard mindmap + netdisk) apps
- agent API keys for fleet read/write

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:03:40 +08:00

309 lines
12 KiB
JavaScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Menu, X } from 'lucide-react';
import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx';
const RELEASE = '0.20.0-suite';
// ---------------------------------------------------------------------------
// Routing
// ---------------------------------------------------------------------------
const NAV = [
{ id: 'home', label: 'Solution' },
{ id: 'agents', label: 'Staff' },
{ id: 'contracts', label: 'Pricing' },
{ id: 'signin', label: 'Login' },
{ id: 'booth', label: 'My Booth' },
];
const VALID_ROUTES = ['home', 'agents', 'booth', 'contracts', 'admin', 'signin'];
function useHashRoute() {
const normalize = () => {
const raw = window.location.hash.replace(/^#\/?/, '');
return VALID_ROUTES.includes(raw) ? raw : 'home';
};
const [route, setRouteState] = useState(normalize);
useEffect(() => {
const onHash = () => setRouteState(normalize());
window.addEventListener('hashchange', onHash);
window.addEventListener('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}`);
}
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); };
const loggedIn = !!auth?.user;
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>
))}
{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>
)}
</>
);
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>
</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>
{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),
})),
[],
);
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>
);
}
function RoutePanel({ route, setRoute, auth }) {
const data = panelData(route);
const wide = route === 'admin' && auth.user?.role === 'admin';
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>
);
}
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>
</>
);
}
export default function Scene2DApp() {
const [route, setRoute] = useHashRoute();
const auth = useAuth();
return (
<main className="px-app">
<h1 className="sr-only">amerc agent mercenary tavern</h1>
<PixelTopbar route={route} setRoute={setRoute} auth={auth} />
<TavernFloor route={route} setRoute={setRoute} auth={auth} />
</main>
);
}