diff --git a/index.html b/index.html index 2e7d3d8..64136fc 100644 --- a/index.html +++ b/index.html @@ -17,7 +17,7 @@ @media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } } - + diff --git a/package.json b/package.json index 5b14bcb..ce37279 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amerc-site", - "version": "2.1.0-roster", + "version": "2.2.0-warcamp", "private": true, "type": "module", "scripts": { diff --git a/src/AgentPlatform.jsx b/src/AgentPlatform.jsx index 82b53f2..9ece105 100644 --- a/src/AgentPlatform.jsx +++ b/src/AgentPlatform.jsx @@ -293,6 +293,73 @@ export function KeysCard() { ); } +// A fielded instance = a soldier: alive (online), standing-by (pending), fallen (lost/down). +const SOL_FIG = { online: 'πŸͺ–', pending: '🧍', lost: 'πŸ›‘οΈ', down: 'πŸ’€' }; +function relHb(at) { if (!at) return 'never'; const s = Math.floor((Date.now() - at) / 1000); if (s < 60) return `${s}s ago`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; return `${Math.floor(m / 60)}h ago`; } +function Soldier({ i, onOpen }) { + const klass = i.status === 'online' ? 'alive' : i.status === 'pending' ? 'pending' : 'fallen'; + return ( + + ); +} + +// Soldier detail: live status + terminal snapshot, and chat with your own soldier. +function SoldierDialog({ instance, onClose, onChanged }) { + const [detail, setDetail] = useState(null); + const [tab, setTab] = useState(instance.status === 'online' ? 'chat' : 'status'); + useEffect(() => { + let alive = true; + const f = () => api(`/agents/instances/${instance.id}${instance.accesskey ? `?accesskey=${encodeURIComponent(instance.accesskey)}` : ''}`).then((d) => { if (alive && d.instance) setDetail({ ...d.instance, tui: d.tui }); }).catch(() => {}); + f(); const t = setInterval(f, 5000); return () => { alive = false; clearInterval(t); }; + }, [instance]); + const live = detail || instance; + const online = live.status === 'online'; + const hb = `while true; do curl -s -XPOST https://amerc.ai/api/agents/instances/${instance.id}/heartbeat -H 'content-type: application/json' -d '{"accesskey":"${instance.accesskey}","status":"online","broker":"my-broker"}' >/dev/null; sleep 10; done`; + const standDown = async () => { + if (!window.confirm('Stand this soldier down? (shuts the instance down)')) return; + try { await api(`/agents/instances/${instance.id}`, { method: 'DELETE', body: { accesskey: instance.accesskey } }); onChanged?.(); onClose(); } catch { /* ignore */ } + }; + return ( + +
+ +
+ #{instance.id} {live.name} + {live.status}{live.broker ? ` Β· broker ${live.broker}` : ''} Β· heartbeat {relHb(live.last_heartbeat)} +
+ {instance.accesskey && copyToast(instance.accesskey)} title="broker accesskey">{instance.accesskey.slice(0, 14)}… ⧉} + +
+
+ + +
+ {tab === 'chat' && (online + ? + :

This soldier is {live.status}. Rouse it from the Status tab, then chat.

)} + {tab === 'status' && ( +
+ {live.skills?.length > 0 &&
skills loaded{live.skills.map((s) => {s})}
} +
+
live terminal Β· refreshes every 5s
+
{live.tui || '(no terminal snapshot yet β€” your broker can post one via the heartbeat `tui` field)'}
+
+ {!online && ( +
+

Fallen until a broker checks in. Paste this anywhere with curl to rouse it:

+
{hb}
+
+ )} +
+ )} +
+ ); +} + export function LegionDashboard({ auth, setRoute }) { const [classes, setClasses] = useState([]); const [insts, setInsts] = useState([]); @@ -301,6 +368,7 @@ export function LegionDashboard({ auth, setRoute }) { const [newInst, setNewInst] = useState(null); const [logOpen, setLogOpen] = useState(null); // chatlog session to reopen const [openCls, setOpenCls] = useState(null); // class modal (same as Browse) + const [soldier, setSoldier] = useState(null); // soldier (instance) detail const [loaded, setLoaded] = useState(false); useEffect(() => { if (!newInst) return; @@ -318,7 +386,7 @@ export function LegionDashboard({ auth, setRoute }) { setInsts(all.flat()); } catch (e) { setErr(e.message); } finally { setLoaded(true); } }, []); - useEffect(() => { if (auth.user) load(); }, [auth.user, load]); + useEffect(() => { if (!auth.user) return undefined; load(); const t = setInterval(load, 15000); return () => clearInterval(t); }, [auth.user, load]); if (!auth.ready) return

Loading…

; if (!auth.user) return ( @@ -342,8 +410,6 @@ export function LegionDashboard({ auth, setRoute }) { 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 (

My Legion

{auth.user.handle}
@@ -409,27 +475,26 @@ export function LegionDashboard({ auth, setRoute }) {
) : ( <> -

My classes β€” your banners on the roster

-
- {classes.map((c) => ( -
- setOpenCls(c.id)} /> -
- {c.visibility} - +

βš” The war camp β€” tap a banner for details, a soldier to command

+
+ {classes.map((c) => { + const squad = insts.filter((i) => i.class_id === c.id); + return ( +
+ +
+ {squad.map((i) => )} + +
-
- ))} + ); + })}
-

My instances

- {insts.map((i) => ( -
- #{i.id} {i.status} - {i.accesskey && copyToast(i.accesskey)}>{i.accesskey.slice(0, 14)}… ⧉} - -
- ))} - {!insts.length &&

No instances. Publish one from a class above.

} )}
@@ -448,6 +513,7 @@ export function LegionDashboard({ auth, setRoute }) {
)} {openCls && { setOpenCls(null); load(); }} />} + {soldier && { setSoldier(null); load(); }} onChanged={load} />} {newInst && (() => { const hb = `while true; do curl -s -XPOST https://amerc.ai/api/agents/instances/${newInst.id}/heartbeat -H 'content-type: application/json' -d '{"accesskey":"${newInst.accesskey}","status":"online","broker":"my-broker"}' >/dev/null; sleep 10; done`; return ( diff --git a/src/Scene2D.jsx b/src/Scene2D.jsx index af7a590..e37fe37 100644 --- a/src/Scene2D.jsx +++ b/src/Scene2D.jsx @@ -11,7 +11,7 @@ import { api } from './api.js'; const Menu = ({ size = 20 }) => (); const X = ({ size = 20 }) => (); -const RELEASE = '2.1.0-roster'; +const RELEASE = '2.2.0-warcamp'; const NAV = [ { id: 'home', label: 'Tavern' }, @@ -476,6 +476,7 @@ function StatusPage() { } const CHANGELOG = [ + { v: '2.2', date: 'Jun 2026', title: 'My Legion becomes a war camp', notes: ['Your classes are banners and each instance is a soldier β€” alive and bobbing when online, standing by when pending, fallen when down', 'Tap a soldier to command it: chat with your own running agent, watch its live terminal, or stand it down', 'Statuses refresh on their own so the camp stays current'] }, { v: '2.1', date: 'Jun 2026', title: 'The roster comes through the door', notes: ['Browse Agents is now a doorway β€” the mercenaries descend and hang on wooden sign-bubbles showing what each one does', 'Open one and its detail opens in an ornate war-banner dialog; instances are shown as a squad β€” living soldiers when online, fallen when down'] }, { v: '2.0', date: 'Jun 2026', title: 'The tavern becomes the interface', notes: ['The home page is now a full-screen game scene β€” every fixture is a door: the grand door is the roster, the bar signs you in, the banners are your Legion, the side passage leads to My Mansion', 'Speech bubbles everywhere: the dancer pitches the tour in a glowing holo-panel, the barkeep greets you by name', 'Details open in ornate in-game dialogs instead of plain pages'] }, { v: '1.3', date: 'Jun 2026', title: 'Mansion learns to handle files', notes: ['File-mapper: your agent maps a file to a stable link β€” every download streams live through your broker, nothing stored on amerc', 'Net-disk opened as a Mansion room: 100 MB per account, with a live usage meter', 'And the roster has a real agent online again β€” say hello to Kebab Webagent'] }, diff --git a/src/styles.css b/src/styles.css index cb6e6e6..a87353f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1765,3 +1765,45 @@ button { .cm-sol-meta { display: flex; align-items: center; gap: 6px; color: #8a7bb0; font-size: 11px; } .cm-sol-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; } @media (max-width: 620px) { .apb-merc { width: 132px; } .apb-doorway { flex-wrap: wrap; } } + +/* ===== My Legion = the war camp (2.2): banners + soldiers ===== */ +.lg-camp { display: grid; grid-template-columns: repeat(auto-fill, minmax(184px, 1fr)); gap: 18px; margin-bottom: 8px; } +.lg-unit { display: flex; flex-direction: column; align-items: center; gap: 10px; } +.lg-banner { position: relative; width: 100%; padding: 14px 10px 26px; display: flex; flex-direction: column; align-items: center; gap: 5px; cursor: pointer; font-family: inherit; color: #eee6ff; background: linear-gradient(180deg, #2a1d4e, #170f2c); border: 1px solid #443a7e; border-top: 4px solid #7a6ab0; clip-path: polygon(0 0, 100% 0, 100% 88%, 50% 100%, 0 88%); transition: transform 0.14s, border-color 0.14s; } +.lg-banner:hover { transform: translateY(-3px); border-top-color: #ffd75e; } +.lg-banner::before { content: ''; position: absolute; top: -10px; left: 50%; width: 8px; height: 8px; border-radius: 50%; background: #ffd75e; transform: translateX(-50%); box-shadow: 0 0 8px #ffd75e; } +.lg-banner-crest { width: 50px; height: 50px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 24px; box-shadow: 0 0 0 2px rgba(255,255,255,0.08); } +.lg-banner strong { font-size: 13.5px; text-align: center; line-height: 1.2; } +.lg-banner-sub { font-size: 10.5px; color: #b6a7d4; } +.lg-banner-count { display: flex; align-items: center; gap: 5px; font-size: 11px; color: #9fb2c8; } +.lg-banner-count i { width: 7px; height: 7px; border-radius: 50%; } +.lg-soldiers { display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; min-height: 30px; } +.lg-sol { display: flex; flex-direction: column; align-items: center; gap: 1px; width: 50px; padding: 5px 2px; border-radius: 9px; cursor: pointer; font-family: inherit; background: #11101e; border: 1px solid #2a2440; transition: transform 0.12s, border-color 0.12s; } +.lg-sol:hover { transform: translateY(-2px); border-color: #6a5a9a; } +.lg-sol-fig { font-size: 22px; line-height: 1; } +.lg-sol-id { font-size: 9.5px; color: #8a7bb0; } +.lg-sol-status { font-size: 8.5px; text-transform: uppercase; letter-spacing: 0.4px; } +.lg-sol.alive { border-color: #1f5642; box-shadow: 0 0 10px rgba(54,240,176,0.12); } +.lg-sol.alive .lg-sol-status { color: #36f0b0; } +@keyframes solbob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } } +.lg-sol.alive .lg-sol-fig { animation: solbob 1.7s ease-in-out infinite; } +.lg-sol.pending .lg-sol-status { color: #f2b85f; } +.lg-sol.fallen { opacity: 0.5; } +.lg-sol.fallen .lg-sol-status { color: #ff7a8c; } +.lg-recruit { width: 50px; height: 56px; border-radius: 9px; border: 1px dashed #4a3f6e; background: #0d0b18; color: #6a5a9a; font-size: 22px; cursor: pointer; transition: border-color 0.12s, color 0.12s; } +.lg-recruit:hover { border-color: #46c8e0; color: #7fd9ea; } +/* soldier dialog */ +.lg-sd-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; } +.lg-sd-meta { flex: 1; min-width: 120px; display: flex; flex-direction: column; gap: 1px; } +.lg-sd-meta strong { color: #eee6ff; font-size: 13px; } +.lg-sd-meta span { color: #8a7bb0; font-size: 11px; } +.lg-sd-key { font-size: 11px; } +.lg-standdown { background: #2a1620 !important; border: 1px solid #5a2030 !important; color: #ff9fb0 !important; } +.lg-sd-tabs { display: flex; gap: 6px; margin-bottom: 10px; } +.lg-sd-tabs button { background: #11101e; border: 1px solid #2a2440; color: #b6a7d4; border-radius: 8px; padding: 5px 14px; font-size: 12px; cursor: pointer; font-family: inherit; } +.lg-sd-tabs button.on { background: #2a2360; border-color: #6a5a9a; color: #fff; } +.lg-sd-status { display: flex; flex-direction: column; gap: 10px; } +.lg-tui { background: #05070c; border: 1px solid #1c2438; border-radius: 9px; overflow: hidden; } +.lg-tui-head { padding: 5px 11px; background: #0d1320; color: #5b6b7d; font-size: 10px; letter-spacing: 0.6px; text-transform: uppercase; border-bottom: 1px solid #1c2438; } +.lg-tui pre { margin: 0; padding: 11px; font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: #9fe6c0; max-height: 240px; overflow: auto; white-space: pre-wrap; word-break: break-word; } +.lg-rouse { margin-top: 2px; }